Scaffold Phase 1 hvac app — NewBook-driven room TRV heating scheduler
Ports the state machine, retry/backoff, and guest-override detection from the retired homeassistant-newbook-heating-component, without depending on Home Assistant. Backend (Fastify/pg) + frontend (React/Vite/TS) following standard stack conventions; LXC 128 (127 was already taken by utilities). MHI/Midea/Daikin/boiler drivers and the shared MQTT broker (LXC 104) are later phases/infra, not included here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
276c04f8c6
42 changed files with 5461 additions and 0 deletions
166
frontend/src/components/AuthGate.tsx
Normal file
166
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { useEffect, useRef, useState, createContext, useContext } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
function getInactivityMs(): number | null {
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) return null
|
||||
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('hnf_inactivity_mins='))
|
||||
if (!c) return null
|
||||
const mins = parseInt(c.split('=')[1])
|
||||
return isNaN(mins) || mins <= 0 ? null : mins * 60 * 1000
|
||||
}
|
||||
|
||||
// Only bounce to the central login when actually embedded in the portal shell.
|
||||
// A directly-opened browser tab must never navigate away from its own scope.
|
||||
function isEmbedded() {
|
||||
return window.top !== window
|
||||
}
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth must be used inside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking')
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/auth/verify?app=hvac', { credentials: 'include' })
|
||||
.then(async r => {
|
||||
if (r.ok) {
|
||||
setUser(await r.json())
|
||||
setState('authed')
|
||||
} else if (isEmbedded()) {
|
||||
window.top!.location.href = `/login?from=${encodeURIComponent('/app/hvac')}`
|
||||
} else {
|
||||
setState('login')
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!isEmbedded()) setState('login') })
|
||||
}, [])
|
||||
|
||||
// Inactivity auto-logout — configurable per device (Admin Settings → Device).
|
||||
useEffect(() => {
|
||||
const ms = getInactivityMs()
|
||||
if (state !== 'authed' || !ms) return
|
||||
const timeoutMs: number = ms
|
||||
|
||||
async function forceLogout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
setUser(null)
|
||||
setState('login')
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(forceLogout, timeoutMs)
|
||||
}
|
||||
|
||||
const events = ['mousemove', 'keydown', 'click', 'touchstart'] as const
|
||||
events.forEach(e => window.addEventListener(e, reset, { passive: true }))
|
||||
reset()
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
events.forEach(e => window.removeEventListener(e, reset))
|
||||
}
|
||||
}, [state])
|
||||
|
||||
async function login(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!res.ok) { setError('Invalid email or password'); return }
|
||||
const verify = await fetch('/api/auth/verify?app=hvac', { credentials: 'include' })
|
||||
if (verify.ok) {
|
||||
setUser(await verify.json())
|
||||
setState('authed')
|
||||
} else {
|
||||
setError("You don't have access to this app.")
|
||||
}
|
||||
} catch {
|
||||
setError('Connection error — please try again')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (state === 'checking') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
height: '100vh', fontFamily: 'var(--font)', color: 'var(--text-mid)'
|
||||
}}>
|
||||
Loading…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (state === 'login') {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||
justifyContent: 'center', height: '100dvh', padding: '1.5rem',
|
||||
background: 'var(--navy-dark)',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--navy)', borderRadius: 'var(--radius)',
|
||||
padding: '2rem', width: '100%', maxWidth: '360px',
|
||||
border: '1px solid var(--surface-2)',
|
||||
}}>
|
||||
<h1 style={{ fontSize: '1.4rem', marginBottom: '1.5rem', color: 'var(--gold)' }}>
|
||||
HVAC
|
||||
</h1>
|
||||
<form onSubmit={login} style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<input
|
||||
type="email" value={email} onChange={e => setEmail(e.target.value)}
|
||||
placeholder="Email" required autoComplete="email"
|
||||
style={inputStyle}
|
||||
/>
|
||||
<input
|
||||
type="password" value={password} onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Password" required autoComplete="current-password"
|
||||
style={inputStyle}
|
||||
/>
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: '0.875rem' }}>{error}</p>}
|
||||
<button type="submit" disabled={loading} style={{
|
||||
background: loading ? 'var(--surface-2)' : 'var(--gold)',
|
||||
color: loading ? 'var(--text-muted)' : 'var(--navy-dark)',
|
||||
border: 'none', borderRadius: '6px', padding: '0.625rem',
|
||||
fontSize: '1rem', fontWeight: 600, marginTop: '0.25rem',
|
||||
}}>
|
||||
{loading ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ user: user! }}>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: 'var(--navy-dark)', border: '1px solid var(--surface-2)',
|
||||
borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem',
|
||||
fontSize: '1rem', width: '100%', outline: 'none',
|
||||
}
|
||||
93
frontend/src/components/DevicePhotoUpload.tsx
Normal file
93
frontend/src/components/DevicePhotoUpload.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { Camera, Image as ImageIcon, Loader2, X } from 'lucide-react'
|
||||
import type { DevicePhoto, PhotoType } from '../types'
|
||||
import { uploadDevicePhoto, deleteDevicePhoto, photoUrl } from '../api'
|
||||
|
||||
// Camera/library capture split: one input with capture="environment" (opens the
|
||||
// device camera directly on mobile), one plain file input (opens the photo
|
||||
// library/file picker) — same split used elsewhere in the stack for mobile uploads.
|
||||
export default function DevicePhotoUpload({ deviceId, photoType, photos, onChanged, canDelete }: {
|
||||
deviceId: number
|
||||
photoType: PhotoType
|
||||
photos: DevicePhoto[]
|
||||
onChanged: () => void
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [lightbox, setLightbox] = useState<string | null>(null)
|
||||
const cameraInput = useRef<HTMLInputElement>(null)
|
||||
const libraryInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const slotPhotos = photos.filter(p => p.photo_type === photoType)
|
||||
|
||||
async function handleFile(file: File | undefined) {
|
||||
if (!file) return
|
||||
setUploading(true); setError('')
|
||||
try {
|
||||
await uploadDevicePhoto(deviceId, file, photoType)
|
||||
onChanged()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await deleteDevicePhoto(id).catch(() => {})
|
||||
onChanged()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<div className="photo-grid">
|
||||
{slotPhotos.map(p => (
|
||||
<div key={p.id} className="photo-thumb-wrap">
|
||||
<img className="photo-thumb" src={photoUrl(p.file_path)} onClick={() => setLightbox(photoUrl(p.file_path))} />
|
||||
{canDelete && (
|
||||
<button className="photo-del" onClick={() => remove(p.id)} title="Delete photo">
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{uploading && (
|
||||
<div className="photo-thumb-wrap">
|
||||
<div className="photo-thumb photo-thumb-uploading">
|
||||
<Loader2 size={20} strokeWidth={1.75} className="spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-sm" onClick={() => cameraInput.current?.click()}>
|
||||
<Camera size={14} strokeWidth={1.75} /> Take photo
|
||||
</button>
|
||||
<input
|
||||
ref={cameraInput}
|
||||
type="file" accept="image/*" capture="environment" style={{ display: 'none' }}
|
||||
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm" onClick={() => libraryInput.current?.click()}>
|
||||
<ImageIcon size={14} strokeWidth={1.75} /> Choose from library
|
||||
</button>
|
||||
<input
|
||||
ref={libraryInput}
|
||||
type="file" accept="image/*" style={{ display: 'none' }}
|
||||
onChange={e => { handleFile(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lightbox && (
|
||||
<div className="lightbox-overlay" onClick={() => setLightbox(null)}>
|
||||
<img className="lightbox-img" src={lightbox} />
|
||||
<button className="lightbox-close" onClick={() => setLightbox(null)}><X size={20} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/Layout.tsx
Normal file
72
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { Thermometer, LayoutGrid, Cpu, Settings, Menu, LogOut } from 'lucide-react'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||
|
||||
const NAV = [
|
||||
{ to: '/dashboard', label: 'Dashboard', icon: LayoutGrid, cap: 'view' },
|
||||
{ to: '/devices', label: 'Devices', icon: Cpu, cap: 'manage_devices' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'settings' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const items = NAV.filter(n => can(user, n.cap))
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
async function logout() {
|
||||
await fetch('/hvac/api/auth/logout', { method: 'POST', credentials: 'include' })
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
useEffect(() => { setMenuOpen(false) }, [location.pathname])
|
||||
|
||||
return (
|
||||
<div className={`app-shell${menuOpen ? ' menu-open' : ''}`}>
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<Thermometer size={18} strokeWidth={1.75} />
|
||||
HVAC
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Icon {...ICON_PROPS} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-user" style={{ whiteSpace: 'normal' }}>
|
||||
<div style={{ fontWeight: 600, color: 'var(--text)', fontSize: '12px', marginBottom: '2px' }}>{user.name}</div>
|
||||
<div style={{ fontSize: '11px', marginBottom: '8px' }}>{user.email}</div>
|
||||
<button onClick={logout} style={{
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
background: 'none', border: 'none', color: 'inherit',
|
||||
fontSize: '12px', padding: 0, cursor: 'pointer',
|
||||
}}>
|
||||
<LogOut size={13} strokeWidth={1.75} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{menuOpen && <div className="menu-backdrop" onClick={() => setMenuOpen(false)} />}
|
||||
|
||||
<header className="top-bar">
|
||||
<button className="top-bar-burger" onClick={() => setMenuOpen(o => !o)}>
|
||||
<Menu size={20} strokeWidth={1.75} />
|
||||
</button>
|
||||
<Thermometer size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
<span className="top-bar-title">HVAC</span>
|
||||
</header>
|
||||
|
||||
<main className="page-content">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/ZoneCard.tsx
Normal file
27
frontend/src/components/ZoneCard.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { Thermometer, BatteryLow } from 'lucide-react'
|
||||
import type { ZoneStatus } from '../types'
|
||||
import { ROOM_STATE_LABELS } from '../types'
|
||||
|
||||
export default function ZoneCard({ zone, onClick }: { zone: ZoneStatus; onClick: () => void }) {
|
||||
const unhealthy = zone.devices.filter(d => d.health_state !== 'healthy').length
|
||||
const lowBattery = zone.devices.some(d => d.battery_pct != null && d.battery_pct < 30)
|
||||
|
||||
return (
|
||||
<div className={`card zone-card st-${zone.room_state}`} onClick={onClick}>
|
||||
<div className="zone-card-title">
|
||||
<span>{zone.name}</span>
|
||||
{!zone.auto_mode && <span className="badge badge-outline">Manual</span>}
|
||||
</div>
|
||||
<div className={`badge badge-st-${zone.room_state}`}>{ROOM_STATE_LABELS[zone.room_state]}</div>
|
||||
<div className="zone-card-temp">
|
||||
<Thermometer size={16} strokeWidth={1.75} style={{ verticalAlign: '-2px', marginRight: 4 }} />
|
||||
{Number(zone.target_temp).toFixed(1)}°C
|
||||
</div>
|
||||
<div className="zone-card-meta">
|
||||
<span>{zone.device_count ?? zone.devices.length} device{(zone.device_count ?? zone.devices.length) === 1 ? '' : 's'}</span>
|
||||
{unhealthy > 0 && <span style={{ color: 'var(--health-degraded)' }}>{unhealthy} needs attention</span>}
|
||||
{lowBattery && <BatteryLow size={13} strokeWidth={1.75} color="var(--health-degraded)" />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
180
frontend/src/components/ZoneDetailModal.tsx
Normal file
180
frontend/src/components/ZoneDetailModal.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Thermometer, History } from 'lucide-react'
|
||||
import type { ZoneStatus, ActivityEntry } from '../types'
|
||||
import { ROOM_STATE_LABELS, can } from '../types'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { updateZone, overrideZone, fetchActivity } from '../api'
|
||||
|
||||
export default function ZoneDetailModal({ zone, onClose, onSaved }: {
|
||||
zone: ZoneStatus
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const canEdit = can(user, 'schedule_edit')
|
||||
const canControl = can(user, 'control')
|
||||
|
||||
const [occupiedTemp, setOccupiedTemp] = useState(zone.occupied_temp)
|
||||
const [vacantTemp, setVacantTemp] = useState(zone.vacant_temp)
|
||||
const [heatingOffset, setHeatingOffset] = useState(zone.heating_offset_min)
|
||||
const [coolingOffset, setCoolingOffset] = useState(zone.cooling_offset_min)
|
||||
const [autoMode, setAutoMode] = useState(zone.auto_mode)
|
||||
const [syncValves, setSyncValves] = useState(zone.sync_valves)
|
||||
const [excludeBathroom, setExcludeBathroom] = useState(zone.exclude_bathroom)
|
||||
const [overrideTemp, setOverrideTemp] = useState(zone.occupied_temp)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [msg, setMsg] = useState('')
|
||||
const [activity, setActivity] = useState<ActivityEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchActivity(zone.id, 20).then(setActivity).catch(() => {})
|
||||
}, [zone.id])
|
||||
|
||||
async function save() {
|
||||
setSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
await updateZone(zone.id, {
|
||||
occupied_temp: Number(occupiedTemp),
|
||||
vacant_temp: Number(vacantTemp),
|
||||
heating_offset_min: heatingOffset,
|
||||
cooling_offset_min: coolingOffset,
|
||||
auto_mode: autoMode,
|
||||
sync_valves: syncValves,
|
||||
exclude_bathroom: excludeBathroom,
|
||||
})
|
||||
setMsg('Saved')
|
||||
onSaved()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function forceTemp() {
|
||||
setSaving(true); setError(''); setMsg('')
|
||||
try {
|
||||
const res = await overrideZone(zone.id, Number(overrideTemp))
|
||||
if (res.ok) {
|
||||
setMsg(`Set to ${overrideTemp}°C (${res.successful}/${res.total} devices) — auto mode disabled`)
|
||||
setAutoMode(false)
|
||||
onSaved()
|
||||
} else {
|
||||
setError(res.error || 'Override failed')
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Override failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{zone.name}</h2>
|
||||
<button className="modal-close" onClick={onClose}><X size={18} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
|
||||
<div className={`badge badge-st-${zone.room_state}`} style={{ marginBottom: 10 }}>
|
||||
{ROOM_STATE_LABELS[zone.room_state]}
|
||||
</div>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{msg && <div className="ok-banner">{msg}</div>}
|
||||
|
||||
<div className="section-title">Devices</div>
|
||||
{zone.devices.length === 0 && <p className="muted">No devices mapped to this zone yet — assign some on the Devices page.</p>}
|
||||
{zone.devices.map(d => (
|
||||
<div key={d.id} className="card" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>{d.location || d.discovered_name || d.external_ref}</div>
|
||||
<div className="muted" style={{ fontSize: 12 }}>
|
||||
{d.current_target_temp != null ? `${Number(d.current_target_temp).toFixed(1)}°C` : '—'}
|
||||
{d.battery_pct != null && ` · ${d.battery_pct}% battery`}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-health-${d.health_state}`}>{d.health_state.replace('_', ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canControl && (
|
||||
<>
|
||||
<div className="section-title">Manual Override</div>
|
||||
<div className="field-row">
|
||||
<div className="field" style={{ flex: 'none', width: 120 }}>
|
||||
<input type="number" step="0.5" value={overrideTemp} onChange={e => setOverrideTemp(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn" disabled={saving} onClick={forceTemp}>
|
||||
<Thermometer size={14} strokeWidth={1.75} /> Force temperature
|
||||
</button>
|
||||
</div>
|
||||
<p className="field-hint">Disables auto mode for this zone until re-enabled below.</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<div className="section-title">Schedule</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Occupied temp (°C)</label>
|
||||
<input type="number" step="0.5" value={occupiedTemp} onChange={e => setOccupiedTemp(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Vacant temp (°C)</label>
|
||||
<input type="number" step="0.5" value={vacantTemp} onChange={e => setVacantTemp(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Heating offset (mins before arrival)</label>
|
||||
<input type="number" value={heatingOffset} onChange={e => setHeatingOffset(parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Cooling offset (mins after departure)</label>
|
||||
<input type="number" value={coolingOffset} onChange={e => setCoolingOffset(parseInt(e.target.value) || 0)} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={autoMode} onChange={e => setAutoMode(e.target.checked)} />
|
||||
Auto mode (NewBook-driven scheduling)
|
||||
</label>
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={syncValves} onChange={e => setSyncValves(e.target.checked)} />
|
||||
Sync guest adjustments across valves in this zone
|
||||
</label>
|
||||
<label className="field-check">
|
||||
<input type="checkbox" checked={excludeBathroom} onChange={e => setExcludeBathroom(e.target.checked)} />
|
||||
Exclude bathroom valve from sync
|
||||
</label>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-primary" disabled={saving} onClick={save}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activity.length > 0 && (
|
||||
<>
|
||||
<div className="section-title"><History size={12} strokeWidth={1.75} style={{ verticalAlign: '-1px', marginRight: 4 }} />Recent activity</div>
|
||||
<div className="timeline">
|
||||
{activity.map(a => (
|
||||
<div key={a.id} className="timeline-item">
|
||||
<div className="timeline-body">
|
||||
{a.note}
|
||||
<div className="timeline-meta">{new Date(a.created_at).toLocaleString()} · {a.source}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue