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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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-wrap { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
.kiosk-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
.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 {
|
.kiosk-block {
|
||||||
position: relative; cursor: pointer; background: var(--card-bg);
|
position: relative; cursor: pointer; background: var(--card-bg);
|
||||||
border: 1px solid var(--card-border); border-radius: var(--radius);
|
border: 1px solid var(--card-border); border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow-sm); padding: 12px; min-height: 96px;
|
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.selected { outline: 2px solid var(--gold); outline-offset: -2px; }
|
||||||
.kiosk-block-check {
|
.kiosk-block-check {
|
||||||
position: absolute; top: 8px; right: 8px; width: 20px; height: 20px; border-radius: 5px;
|
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);
|
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.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-zone { font-size: 13px; font-weight: 700; color: var(--text-dark); padding-right: 22px; }
|
||||||
.kiosk-block-location { font-weight: 500; color: var(--text-mid); }
|
.kiosk-block-body { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex: 1; }
|
||||||
.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-bulk-bar {
|
.kiosk-bulk-bar {
|
||||||
position: sticky; bottom: 0; display: flex; align-items: center; gap: 12px;
|
position: sticky; bottom: 0; display: flex; align-items: center; gap: 12px;
|
||||||
|
|
|
||||||
|
|
@ -3,48 +3,39 @@ import { Thermometer, CheckSquare, X } from 'lucide-react'
|
||||||
import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api'
|
import { kioskLogin, kioskFetchStatus, KioskSessionError } from '../api'
|
||||||
import type { DeviceSummary, ZoneStatus } from '../types'
|
import type { DeviceSummary, ZoneStatus } from '../types'
|
||||||
import { KIOSK_DEVICE_TYPES } 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 KioskModal from '../components/KioskModal'
|
||||||
import KioskBulkPanel from '../components/KioskBulkPanel'
|
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
|
// 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
|
// 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).
|
// data is already push/poll-cached (MQTT for TRV/MHI, a 60s poller for Daikin).
|
||||||
const POLL_MS = 12_000
|
const POLL_MS = 12_000
|
||||||
|
|
||||||
interface FlatDevice { device: DeviceSummary; zoneName: string }
|
function liveDevicesOf(zone: ZoneStatus): DeviceSummary[] {
|
||||||
|
return zone.devices.filter(d => KIOSK_DEVICE_TYPES.includes(d.device_type))
|
||||||
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 || ''))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The wall-panel kiosk view — a dedicated route/login, deliberately outside the
|
// The wall-panel kiosk view — a dedicated route/login, deliberately outside the
|
||||||
// normal staff AuthGate/Layout (see App.tsx). One master panel: every live-
|
// normal staff AuthGate/Layout (see App.tsx). One master panel: one tile per
|
||||||
// controllable device (shelly_trv / mhi_mqtt / daikin) in one scrollable grid of
|
// ROOM/ZONE (same as the Dashboard's ZoneCard — a room's TRV and AC show
|
||||||
// small blocks; tap a block for the full control modal, or toggle Select for
|
// together, not as separate tiles) in a scrollable grid; tap a tile for that
|
||||||
// type-aware bulk editing. See project_hvac_kiosk memory for the design decisions
|
// zone's full control modal, or toggle Select for type-aware bulk editing
|
||||||
// behind this (access model, layout, bulk-edit mechanism).
|
// across zones. See project_hvac_kiosk memory for the design decisions behind
|
||||||
|
// this (access model, layout, bulk-edit mechanism).
|
||||||
export default function Kiosk() {
|
export default function Kiosk() {
|
||||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking')
|
const [authState, setAuthState] = useState<'checking' | 'login' | 'authed'>('checking')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [loginError, setLoginError] = useState('')
|
const [loginError, setLoginError] = useState('')
|
||||||
const [loginLoading, setLoginLoading] = useState(false)
|
const [loginLoading, setLoginLoading] = useState(false)
|
||||||
|
|
||||||
const [flatDevices, setFlatDevices] = useState<FlatDevice[]>([])
|
const [zones, setZones] = useState<ZoneStatus[]>([])
|
||||||
const [loadError, setLoadError] = useState('')
|
const [loadError, setLoadError] = useState('')
|
||||||
|
|
||||||
const [selectionMode, setSelectionMode] = useState(false)
|
const [selectionMode, setSelectionMode] = useState(false)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
const [selectedZoneIds, setSelectedZoneIds] = useState<Set<number>>(new Set())
|
||||||
const [editingDevice, setEditingDevice] = useState<DeviceSummary | null>(null)
|
const [editingZone, setEditingZone] = useState<ZoneStatus | null>(null)
|
||||||
const [bulkOpen, setBulkOpen] = useState(false)
|
const [bulkOpen, setBulkOpen] = useState(false)
|
||||||
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
@ -52,7 +43,7 @@ export default function Kiosk() {
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
kioskFetchStatus()
|
kioskFetchStatus()
|
||||||
.then(s => {
|
.then(s => {
|
||||||
setFlatDevices(flattenDevices(s.zones))
|
setZones(s.zones.filter(z => liveDevicesOf(z).length > 0))
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
setAuthState('authed')
|
setAuthState('authed')
|
||||||
})
|
})
|
||||||
|
|
@ -89,30 +80,31 @@ export default function Kiosk() {
|
||||||
|
|
||||||
function toggleSelectionMode() {
|
function toggleSelectionMode() {
|
||||||
setSelectionMode(m => !m)
|
setSelectionMode(m => !m)
|
||||||
setSelectedIds(new Set())
|
setSelectedZoneIds(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
function tapDevice(d: DeviceSummary) {
|
function tapZone(zone: ZoneStatus) {
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
setSelectedIds(prev => {
|
setSelectedZoneIds(prev => {
|
||||||
const next = new Set(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
|
return next
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setEditingDevice(d)
|
setEditingZone(zone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeBulk() {
|
function closeBulk() {
|
||||||
setBulkOpen(false)
|
setBulkOpen(false)
|
||||||
setSelectionMode(false)
|
setSelectionMode(false)
|
||||||
setSelectedIds(new Set())
|
setSelectedZoneIds(new Set())
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDevices = flatDevices.filter(fd => selectedIds.has(fd.device.id)).map(fd => fd.device)
|
// Bulk edit works across whichever zones are selected — gather every live
|
||||||
const editingZoneName = editingDevice ? flatDevices.find(fd => fd.device.id === editingDevice.id)?.zoneName : ''
|
// 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') {
|
if (authState === 'checking') {
|
||||||
return <div className="kiosk-shell kiosk-center"><p className="muted">Loading…</p></div>
|
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-wrap">
|
||||||
<div className="kiosk-grid">
|
<div className="kiosk-grid">
|
||||||
{flatDevices.map(({ device, zoneName }) => (
|
{zones.map(zone => (
|
||||||
<KioskDeviceBlock
|
<KioskZoneBlock
|
||||||
key={device.id}
|
key={zone.id}
|
||||||
device={device}
|
zone={zone}
|
||||||
zoneName={zoneName}
|
liveDevices={liveDevicesOf(zone)}
|
||||||
selectionMode={selectionMode}
|
selectionMode={selectionMode}
|
||||||
selected={selectedIds.has(device.id)}
|
selected={selectedZoneIds.has(zone.id)}
|
||||||
onTap={() => tapDevice(device)}
|
onTap={() => tapZone(zone)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{flatDevices.length === 0 && !loadError && (
|
{zones.length === 0 && !loadError && (
|
||||||
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
|
<p className="muted">No live-controllable devices assigned to a zone yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectionMode && selectedIds.size > 0 && (
|
{selectionMode && selectedZoneIds.size > 0 && (
|
||||||
<div className="kiosk-bulk-bar">
|
<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-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} />
|
<X size={13} strokeWidth={1.75} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingDevice && (
|
{editingZone && (
|
||||||
<KioskModal
|
<KioskModal title={editingZone.name} onClose={() => { setEditingZone(null); load() }}>
|
||||||
title={editingDevice.location ? `${editingZoneName} — ${editingDevice.location}` : editingZoneName || 'Device'}
|
<KioskZonePanel devices={liveDevicesOf(editingZone)} />
|
||||||
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} />}
|
|
||||||
</KioskModal>
|
</KioskModal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bulkOpen && (
|
{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} />
|
<KioskBulkPanel devices={selectedDevices} onDone={closeBulk} />
|
||||||
</KioskModal>
|
</KioskModal>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue