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>
188 lines
7 KiB
TypeScript
188 lines
7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
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 KioskZoneBlock from '../components/KioskZoneBlock'
|
|
import KioskZonePanel from '../components/KioskZonePanel'
|
|
import KioskModal from '../components/KioskModal'
|
|
import KioskBulkPanel from '../components/KioskBulkPanel'
|
|
|
|
// 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
|
|
|
|
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: 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 [zones, setZones] = useState<ZoneStatus[]>([])
|
|
const [loadError, setLoadError] = useState('')
|
|
|
|
const [selectionMode, setSelectionMode] = useState(false)
|
|
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)
|
|
|
|
const load = useCallback(() => {
|
|
kioskFetchStatus()
|
|
.then(s => {
|
|
setZones(s.zones.filter(z => liveDevicesOf(z).length > 0))
|
|
setLoadError('')
|
|
setAuthState('authed')
|
|
})
|
|
.catch(e => {
|
|
if (e instanceof KioskSessionError) { setAuthState('login'); return }
|
|
setLoadError(e instanceof Error ? e.message : 'Failed to load device status')
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
useEffect(() => {
|
|
if (authState !== 'authed') {
|
|
if (pollRef.current) clearInterval(pollRef.current)
|
|
return
|
|
}
|
|
pollRef.current = setInterval(load, POLL_MS)
|
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
|
}, [authState, load])
|
|
|
|
async function handleLogin(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setLoginLoading(true); setLoginError('')
|
|
try {
|
|
await kioskLogin(password)
|
|
setPassword('')
|
|
load()
|
|
} catch (e) {
|
|
setLoginError(e instanceof Error ? e.message : 'Login failed')
|
|
} finally {
|
|
setLoginLoading(false)
|
|
}
|
|
}
|
|
|
|
function toggleSelectionMode() {
|
|
setSelectionMode(m => !m)
|
|
setSelectedZoneIds(new Set())
|
|
}
|
|
|
|
function tapZone(zone: ZoneStatus) {
|
|
if (selectionMode) {
|
|
setSelectedZoneIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(zone.id)) next.delete(zone.id); else next.add(zone.id)
|
|
return next
|
|
})
|
|
} else {
|
|
setEditingZone(zone)
|
|
}
|
|
}
|
|
|
|
function closeBulk() {
|
|
setBulkOpen(false)
|
|
setSelectionMode(false)
|
|
setSelectedZoneIds(new Set())
|
|
load()
|
|
}
|
|
|
|
// 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>
|
|
}
|
|
|
|
if (authState === 'login') {
|
|
return (
|
|
<div className="kiosk-shell kiosk-center">
|
|
<div className="kiosk-login-card">
|
|
<Thermometer size={28} strokeWidth={1.5} style={{ color: 'var(--gold)', marginBottom: 8 }} />
|
|
<h1 style={{ fontSize: '1.3rem', margin: '0 0 1.25rem' }}>HVAC Kiosk</h1>
|
|
<form onSubmit={handleLogin} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
<input
|
|
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
|
placeholder="Kiosk password" autoFocus required autoComplete="off"
|
|
/>
|
|
{loginError && <div className="error-banner">{loginError}</div>}
|
|
<button className="btn btn-primary" type="submit" disabled={loginLoading}>
|
|
{loginLoading ? 'Signing in…' : 'Unlock'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="kiosk-shell">
|
|
<header className="kiosk-header">
|
|
<span className="kiosk-header-title">
|
|
<Thermometer size={18} strokeWidth={1.75} /> HVAC Kiosk
|
|
</span>
|
|
<button className={`btn btn-sm${selectionMode ? ' btn-primary' : ''}`} onClick={toggleSelectionMode}>
|
|
<CheckSquare size={14} strokeWidth={1.75} /> {selectionMode ? 'Cancel select' : 'Select'}
|
|
</button>
|
|
</header>
|
|
|
|
{loadError && <div className="error-banner kiosk-error">{loadError}</div>}
|
|
|
|
<div className="kiosk-grid-wrap">
|
|
<div className="kiosk-grid">
|
|
{zones.map(zone => (
|
|
<KioskZoneBlock
|
|
key={zone.id}
|
|
zone={zone}
|
|
liveDevices={liveDevicesOf(zone)}
|
|
selectionMode={selectionMode}
|
|
selected={selectedZoneIds.has(zone.id)}
|
|
onTap={() => tapZone(zone)}
|
|
/>
|
|
))}
|
|
{zones.length === 0 && !loadError && (
|
|
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectionMode && selectedZoneIds.size > 0 && (
|
|
<div className="kiosk-bulk-bar">
|
|
<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={() => setSelectedZoneIds(new Set())} aria-label="Clear selection">
|
|
<X size={13} strokeWidth={1.75} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{editingZone && (
|
|
<KioskModal title={editingZone.name} onClose={() => { setEditingZone(null); load() }}>
|
|
<KioskZonePanel devices={liveDevicesOf(editingZone)} />
|
|
</KioskModal>
|
|
)}
|
|
|
|
{bulkOpen && (
|
|
<KioskModal title={`Edit ${selectedZoneIds.size} zone${selectedZoneIds.size === 1 ? '' : 's'}`} wide onClose={closeBulk}>
|
|
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
|
|
</KioskModal>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|