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:
parent
2bae718b50
commit
930f3d867f
5 changed files with 155 additions and 116 deletions
|
|
@ -1,57 +0,0 @@
|
|||
import { Thermometer, Snowflake, Flame, Fan, Droplet, Heater, Check } from 'lucide-react'
|
||||
import type { DeviceSummary } from '../types'
|
||||
|
||||
const MODE_ICONS: Record<string, typeof Snowflake> = {
|
||||
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 (
|
||||
<div
|
||||
className={`kiosk-block${selected ? ' selected' : ''}${on ? ' on' : ''}`}
|
||||
onClick={onTap}
|
||||
role="button"
|
||||
>
|
||||
{selectionMode && (
|
||||
<div className="kiosk-block-check">{selected && <Check size={14} strokeWidth={2.5} />}</div>
|
||||
)}
|
||||
<div className="kiosk-block-zone">
|
||||
{zoneName}
|
||||
{device.location && <span className="kiosk-block-location"> · {device.location}</span>}
|
||||
</div>
|
||||
<div className="kiosk-block-main">
|
||||
<ModeIcon size={22} strokeWidth={1.75} />
|
||||
<span className="kiosk-block-setpoint">{fmtTemp(device.current_target_temp)}</span>
|
||||
</div>
|
||||
<div className="kiosk-block-sub">
|
||||
<Thermometer size={12} strokeWidth={1.75} /> {fmtTemp(device.current_room_temp)}
|
||||
{!isTrv && device.fan_speed && <span className="kiosk-block-fan">{device.fan_speed}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
84
frontend/src/components/KioskZoneBlock.tsx
Normal file
84
frontend/src/components/KioskZoneBlock.tsx
Normal file
|
|
@ -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<string, typeof Snowflake> = {
|
||||
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 (
|
||||
<div className={`kiosk-block${selected ? ' selected' : ''}`} onClick={onTap} role="button">
|
||||
{selectionMode && (
|
||||
<div className="kiosk-block-check">{selected && <Check size={14} strokeWidth={2.5} />}</div>
|
||||
)}
|
||||
<div className="kiosk-block-zone">{zone.name}</div>
|
||||
|
||||
<div className="kiosk-block-body">
|
||||
{trvs.length > 0 && (
|
||||
<div className="rad-row">
|
||||
{trvs.slice(0, 3).map(d => (
|
||||
<div key={d.id} className={`rad-icon ${d.heating_active ? 'heating' : 'idle'}`}>
|
||||
<Heater size={16} strokeWidth={1.75} />
|
||||
<span>{fmtTemp(d.current_target_temp)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ac && (
|
||||
<div className={`ac-block${acOn ? '' : ' off'}`}>
|
||||
{ac.remote_locked && <Lock size={12} strokeWidth={1.75} className="ac-lock" />}
|
||||
<div className="ac-mode-row">
|
||||
<ModeIcon size={18} strokeWidth={1.75} />
|
||||
<div className="fan-bar">
|
||||
{[1, 2, 3, 4].map(n => <span key={n} className={n <= fanLevel ? 'on' : ''} />)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ac-temp">{fmtTemp(ac.current_target_temp)} / {fmtTemp(ac.current_room_temp)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{trvs.length === 0 && !ac && <span className="muted" style={{ fontSize: 11.5 }}>No live devices</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/KioskZonePanel.tsx
Normal file
27
frontend/src/components/KioskZonePanel.tsx
Normal file
|
|
@ -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 <p className="muted">No live-controllable devices in this zone.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{devices.map(d => (
|
||||
<div key={d.id} className="card">
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
|
||||
{d.device_type === 'shelly_trv' && <TrvControlPanel deviceId={d.id} canControl={true} />}
|
||||
{d.device_type === 'mhi_mqtt' && <MhiControlPanel deviceId={d.id} canControl={true} />}
|
||||
{d.device_type === 'daikin' && <DaikinControlPanel deviceId={d.id} canControl={true} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue