Daikin BRP069/BRP072-class units are WiFi/DHCP with no fixed IP. hvac's backend runs as a Docker container on its own bridge network (not directly on the LXC's admin-VLAN NIC), so UDP broadcast discovery risked silently failing to egress — instead, every unit is identified by MAC (external_ref), and a background poller periodically HTTP-sweeps the configured subnet CIDR and matches replies by MAC, self-healing zone_devices.device_ip whenever a lease changes. Only needs plain outbound HTTP, same path already proven by the MHI Modbus TCP driver. - lib/drivers/daikin.js: discover()/getStatus()/setTarget() against the local /common/basic_info + /aircon/get_control_info + /aircon/set_control_info API (confirmed live against firmware 4_2_303, 2026-08-25). Mode/fan-rate encoding taken from community reverse-engineering, not yet verified against a live setTarget() call — flagged in comments for first real control test. - lib/daikin-poller.js: 60s status poll (cached IP) + 5min subnet resweep (self-heal IP + auto-register new units, never auto-assigned to a zone). - lib/device-registry.js: shared discover-upsert, extracted so both the manual Discover button and the background sweep record device_ip the same way. - ac-control.js / routes/override.js: daikin-status/daikin-control endpoints and commandAcDevice() branch, mirroring the existing MHI control path. - Settings page: new "Daikin subnet (CIDR)" field (daikin_subnet_cidr config key) — sweeps do nothing until this is set. - Frontend: DaikinControlPanel.tsx (on/off/mode/setpoint/fan), wired into ZoneDetailModal; daikin moved from NOT_YET_IMPLEMENTED to a real driver. Backend node --check and frontend tsc --noEmit both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
468 lines
21 KiB
TypeScript
468 lines
21 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Wifi, WifiOff } from 'lucide-react'
|
|
import {
|
|
fetchNewbookSites, saveExcludedSites, testNewbookConnection, syncZonesFromNewbook,
|
|
fetchConfig, updateConfig, fetchMqttStatus, fetchStatus, createZone,
|
|
} from '../api'
|
|
import type { NewbookSite, AppConfig, HvacDefaults, HvacAcTemplate, HvacWeatherTier } from '../types'
|
|
import { DEFAULT_HVAC_TEMPLATE, MHI_MODES, MHI_FAN_SPEEDS } from '../types'
|
|
|
|
export default function Settings() {
|
|
const [sites, setSites] = useState<NewbookSite[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
const [msg, setMsg] = useState('')
|
|
const [testing, setTesting] = useState(false)
|
|
const [syncing, setSyncing] = useState(false)
|
|
|
|
const [config, setConfig] = useState<AppConfig | null>(null)
|
|
const [automationEnabled, setAutomationEnabled] = useState(true)
|
|
const [automationSaving, setAutomationSaving] = useState(false)
|
|
const [pollMinutes, setPollMinutes] = useState(10)
|
|
const [defaultArrival, setDefaultArrival] = useState('15:00:00')
|
|
const [defaultDeparture, setDefaultDeparture] = useState('10:00:00')
|
|
const [maintUrl, setMaintUrl] = useState('')
|
|
const [maintKey, setMaintKey] = useState('')
|
|
const [daikinCidr, setDaikinCidr] = useState('')
|
|
const [configSaving, setConfigSaving] = useState(false)
|
|
|
|
// Default occupied/vacant templates — every room inherits these unless it sets its
|
|
// own override (ZoneDetailModal's per-field "Override" toggles).
|
|
const [defaults, setDefaults] = useState<HvacDefaults>(DEFAULT_HVAC_TEMPLATE)
|
|
const [defaultsSaving, setDefaultsSaving] = useState(false)
|
|
|
|
const [mqttConnected, setMqttConnected] = useState<boolean | null>(null)
|
|
const [mqttNote, setMqttNote] = useState('')
|
|
|
|
const [weatherPeakC, setWeatherPeakC] = useState<number | null>(null)
|
|
const [weatherTier, setWeatherTier] = useState<string | null>(null)
|
|
const [weatherLoaded, setWeatherLoaded] = useState(false)
|
|
|
|
const [publicAreaName, setPublicAreaName] = useState('')
|
|
const [creatingZone, setCreatingZone] = useState(false)
|
|
|
|
function loadSites() {
|
|
fetchNewbookSites()
|
|
.then(res => setSites(res.sites))
|
|
.catch(e => setError(e instanceof Error ? e.message : 'Failed to load NewBook sites'))
|
|
.finally(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadSites()
|
|
fetchConfig().then(cfg => {
|
|
setConfig(cfg)
|
|
setAutomationEnabled(cfg.automation_enabled ?? true)
|
|
setPollMinutes(cfg.poll_interval_minutes ?? 10)
|
|
setDefaultArrival(cfg.default_arrival_time ?? '15:00:00')
|
|
setDefaultDeparture(cfg.default_departure_time ?? '10:00:00')
|
|
setMaintUrl(cfg.maintenance_url ?? '')
|
|
setMaintKey(cfg.maintenance_api_key ?? '')
|
|
setDaikinCidr(cfg.daikin_subnet_cidr ?? '')
|
|
// Merge onto the shipped default rather than replacing outright — an hvac_defaults
|
|
// row saved before weather compensation existed won't have ac_occupied_tiers yet.
|
|
setDefaults({ ...DEFAULT_HVAC_TEMPLATE, ...cfg.hvac_defaults, ac_occupied_tiers: cfg.hvac_defaults?.ac_occupied_tiers ?? DEFAULT_HVAC_TEMPLATE.ac_occupied_tiers })
|
|
}).catch(() => {})
|
|
fetchMqttStatus().then(s => { setMqttConnected(s.connected); setMqttNote(s.note) }).catch(() => {})
|
|
fetchStatus()
|
|
.then(s => { setWeatherPeakC(s.weather_peak_c); setWeatherTier(s.weather_tier) })
|
|
.catch(() => {})
|
|
.finally(() => setWeatherLoaded(true))
|
|
}, [])
|
|
|
|
function toggleExcluded(siteId: string) {
|
|
setSites(sites.map(s => s.site_id === siteId ? { ...s, excluded: !s.excluded } : s))
|
|
}
|
|
|
|
async function saveSites() {
|
|
setError(''); setMsg('')
|
|
try {
|
|
await saveExcludedSites(sites.filter(s => s.excluded).map(s => s.site_id))
|
|
setMsg('Saved'); setTimeout(() => setMsg(''), 2500)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
}
|
|
}
|
|
|
|
async function handleTest() {
|
|
setTesting(true); setError(''); setMsg('')
|
|
try {
|
|
const res = await testNewbookConnection()
|
|
setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Test failed')
|
|
} finally {
|
|
setTesting(false)
|
|
}
|
|
}
|
|
|
|
async function handleSync() {
|
|
setSyncing(true); setError(''); setMsg('')
|
|
try {
|
|
const res = await syncZonesFromNewbook()
|
|
setMsg(`Synced: ${res.created} created, ${res.updated} updated (${res.total} active sites)`)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Sync failed')
|
|
} finally {
|
|
setSyncing(false)
|
|
}
|
|
}
|
|
|
|
async function handleAutomationToggle() {
|
|
const next = !automationEnabled
|
|
setAutomationSaving(true); setError(''); setMsg('')
|
|
try {
|
|
await updateConfig('automation_enabled', next)
|
|
setAutomationEnabled(next)
|
|
setMsg(next ? 'Automation resumed' : 'Automation paused — device setpoints will no longer change until resumed')
|
|
setTimeout(() => setMsg(''), 4000)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Failed to update automation state')
|
|
} finally {
|
|
setAutomationSaving(false)
|
|
}
|
|
}
|
|
|
|
async function saveGeneralConfig() {
|
|
setConfigSaving(true); setError(''); setMsg('')
|
|
try {
|
|
await Promise.all([
|
|
updateConfig('poll_interval_minutes', pollMinutes),
|
|
updateConfig('default_arrival_time', defaultArrival),
|
|
updateConfig('default_departure_time', defaultDeparture),
|
|
updateConfig('maintenance_url', maintUrl),
|
|
updateConfig('maintenance_api_key', maintKey),
|
|
updateConfig('daikin_subnet_cidr', daikinCidr),
|
|
])
|
|
setMsg('Settings saved'); setTimeout(() => setMsg(''), 2500)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setConfigSaving(false)
|
|
}
|
|
}
|
|
|
|
function updateAcDefault(which: 'ac_occupied' | 'ac_vacant', field: keyof HvacAcTemplate, value: string | number) {
|
|
setDefaults(d => ({ ...d, [which]: { ...d[which], [field]: value } }))
|
|
}
|
|
|
|
function updateTier(index: number, field: keyof HvacWeatherTier, value: string | number) {
|
|
setDefaults(d => ({
|
|
...d,
|
|
ac_occupied_tiers: d.ac_occupied_tiers.map((t, i) => i === index ? { ...t, [field]: value } : t),
|
|
}))
|
|
}
|
|
|
|
async function saveDefaults() {
|
|
setDefaultsSaving(true); setError(''); setMsg('')
|
|
try {
|
|
await updateConfig('hvac_defaults', defaults)
|
|
setMsg('Default templates saved'); setTimeout(() => setMsg(''), 2500)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Save failed')
|
|
} finally {
|
|
setDefaultsSaving(false)
|
|
}
|
|
}
|
|
|
|
async function handleCreatePublicArea(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!publicAreaName.trim()) return
|
|
setCreatingZone(true); setError(''); setMsg('')
|
|
try {
|
|
await createZone({ name: publicAreaName.trim(), zone_type: 'public_area' })
|
|
setPublicAreaName('')
|
|
setMsg('Public area zone created — assign devices to it on the Devices page.')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Create failed')
|
|
} finally {
|
|
setCreatingZone(false)
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="page"><p className="muted">Loading…</p></div>
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-header">
|
|
<h1>Settings</h1>
|
|
</div>
|
|
|
|
{error && <div className="error-banner">{error}</div>}
|
|
{msg && <div className="ok-banner">{msg}</div>}
|
|
|
|
<div className="section-title">Automation</div>
|
|
<div className="card" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, marginBottom: 24 }}>
|
|
<div>
|
|
<div style={{ fontWeight: 600, color: automationEnabled ? 'var(--health-healthy)' : 'var(--danger)' }}>
|
|
{automationEnabled ? 'Automation is ON' : 'Automation is PAUSED'}
|
|
</div>
|
|
<div className="muted" style={{ fontSize: 13, marginTop: 2 }}>
|
|
Master switch for every zone — overrides each zone's own auto mode. While paused, NewBook polling,
|
|
room-state tracking and activity logging all keep running as normal — only setpoint changes to
|
|
TRVs/aircon devices are skipped.
|
|
</div>
|
|
</div>
|
|
<button
|
|
className={`btn ${automationEnabled ? '' : 'btn-primary'}`}
|
|
disabled={automationSaving}
|
|
onClick={handleAutomationToggle}
|
|
>
|
|
{automationSaving ? 'Saving…' : automationEnabled ? 'Pause Automation' : 'Resume Automation'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="section-title">MQTT Broker</div>
|
|
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{mqttConnected ? <Wifi size={16} strokeWidth={1.75} color="var(--health-healthy)" /> : <WifiOff size={16} strokeWidth={1.75} color="var(--danger)" />}
|
|
<span>{mqttNote || 'Checking…'}</span>
|
|
</div>
|
|
|
|
<div className="section-title">NewBook Rooms</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Untick rooms that shouldn't get NewBook-driven heating scheduling (e.g. owner-occupied or out-of-service rooms).
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 12 }}>
|
|
{sites.map(s => (
|
|
<label key={s.site_id} className="field-check" style={{
|
|
padding: '8px 12px', border: '1px solid var(--card-border)', borderRadius: 8,
|
|
background: s.excluded ? 'var(--body-bg)' : 'var(--card-bg)',
|
|
}}>
|
|
<input type="checkbox" checked={!s.excluded} onChange={() => toggleExcluded(s.site_id)} />
|
|
<span style={{ flex: 1, textDecoration: s.excluded ? 'line-through' : 'none', color: s.excluded ? 'var(--text-mid)' : 'var(--text-dark)' }}>
|
|
{s.site_name}
|
|
</span>
|
|
{s.category_name && <span className="muted" style={{ fontSize: 12 }}>{s.category_name}</span>}
|
|
</label>
|
|
))}
|
|
{sites.length === 0 && <p className="muted">No sites returned — check NewBook connection.</p>}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 24 }}>
|
|
<button className="btn btn-primary" onClick={saveSites}>Save Visibility</button>
|
|
<button className="btn" disabled={testing} onClick={handleTest}>{testing ? 'Testing…' : 'Test NewBook Connection'}</button>
|
|
<button className="btn" disabled={syncing} onClick={handleSync}>{syncing ? 'Syncing…' : 'Sync Zones from NewBook'}</button>
|
|
</div>
|
|
|
|
<div className="section-title">Add a Public Area</div>
|
|
<form onSubmit={handleCreatePublicArea} style={{ display: 'flex', gap: 8, marginBottom: 24 }}>
|
|
<input
|
|
type="text" placeholder="e.g. Lobby, Restaurant, Bar"
|
|
value={publicAreaName} onChange={e => setPublicAreaName(e.target.value)}
|
|
style={{ flex: 1, border: '1px solid var(--card-border)', borderRadius: 8, padding: '8px 10px' }}
|
|
/>
|
|
<button className="btn btn-primary" disabled={creatingZone} type="submit">Add zone</button>
|
|
</form>
|
|
|
|
<div className="section-title">Scheduling</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Poll interval (minutes)</label>
|
|
<input type="number" min={1} value={pollMinutes} onChange={e => setPollMinutes(parseInt(e.target.value) || 10)} />
|
|
</div>
|
|
<div className="field">
|
|
<label>Default arrival time</label>
|
|
<input type="text" value={defaultArrival} onChange={e => setDefaultArrival(e.target.value)} placeholder="15:00:00" />
|
|
</div>
|
|
<div className="field">
|
|
<label>Default departure time</label>
|
|
<input type="text" value={defaultDeparture} onChange={e => setDefaultDeparture(e.target.value)} placeholder="10:00:00" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section-title">Default Templates</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Fallback occupied/vacant state for every room's TRVs and aircon. Any room can override
|
|
individual values on its own card (Dashboard → click the room → Schedule section).
|
|
</p>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Default arrival preparing offset (mins)</label>
|
|
<input
|
|
type="number" value={defaults.arrival_prep_offset_min}
|
|
onChange={e => setDefaults(d => ({ ...d, arrival_prep_offset_min: parseInt(e.target.value) || 0 }))}
|
|
/>
|
|
</div>
|
|
<div className="field">
|
|
<label>Default departing preparation offset (mins)</label>
|
|
<input
|
|
type="number" value={defaults.departure_prep_offset_min}
|
|
onChange={e => setDefaults(d => ({ ...d, departure_prep_offset_min: parseInt(e.target.value) || 0 }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default Aircon</div>
|
|
<div className="table-wrap" style={{ marginBottom: 16 }}>
|
|
<table className="data">
|
|
<thead><tr><th></th><th>Occupied</th><th>Vacant</th></tr></thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>On/Off</td>
|
|
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
|
<td key={which}>
|
|
<select value={defaults[which].power} onChange={e => updateAcDefault(which, 'power', e.target.value)}>
|
|
<option value="on">On</option>
|
|
<option value="off">Off</option>
|
|
</select>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr>
|
|
<td>Mode</td>
|
|
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
|
<td key={which}>
|
|
<select value={defaults[which].mode} onChange={e => updateAcDefault(which, 'mode', e.target.value)}>
|
|
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
|
</select>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr>
|
|
<td>Setpoint (°C)</td>
|
|
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
|
<td key={which}>
|
|
<input
|
|
type="number" step="0.5" style={{ width: 90 }}
|
|
value={defaults[which].setpoint}
|
|
onChange={e => updateAcDefault(which, 'setpoint', parseFloat(e.target.value) || 0)}
|
|
/>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
<tr>
|
|
<td>Fan speed</td>
|
|
{(['ac_occupied', 'ac_vacant'] as const).map(which => (
|
|
<td key={which}>
|
|
<select value={defaults[which].fan_speed} onChange={e => updateAcDefault(which, 'fan_speed', e.target.value)}>
|
|
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
|
</select>
|
|
</td>
|
|
))}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="section-title">Weather Compensation</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Overrides the Occupied aircon template above on a hot day. At check-in/arrival, the scheduler
|
|
checks today's forecast peak (from Settings → Integrations → Hotel Location / Met Office DataHub,
|
|
falls back to Open-Meteo with no key needed) and uses the highest tier below whose threshold it
|
|
clears — below every threshold, the Default Aircon "Occupied" row above still applies unchanged.
|
|
Only re-checked at the moment a room becomes occupied, never mid-stay, so it can't override a
|
|
guest's own adjustment later in their booking.
|
|
{weatherPeakC != null && (
|
|
<> Today's forecast peak: <strong>{weatherPeakC}°C</strong>{weatherTier ? <> — currently in the <strong>{weatherTier}</strong> tier</> : ' — below every tier'}.</>
|
|
)}
|
|
</p>
|
|
{weatherLoaded && weatherPeakC == null && (
|
|
<p className="field-hint" style={{ marginBottom: 10, color: 'var(--danger)' }}>
|
|
No forecast available right now — check Hotel Location and Met Office DataHub under Settings →
|
|
Integrations are saved correctly (both need a Test to pass), or check hvac-backend's logs for
|
|
a "[weather]" line explaining why.
|
|
</p>
|
|
)}
|
|
<div className="table-wrap" style={{ marginBottom: 24 }}>
|
|
<table className="data">
|
|
<thead><tr><th>Tier</th><th>If peak above (°C)</th><th>On/Off</th><th>Mode</th><th>Setpoint (°C)</th><th>Fan speed</th></tr></thead>
|
|
<tbody>
|
|
{defaults.ac_occupied_tiers.map((tier, i) => (
|
|
<tr key={tier.name}>
|
|
<td style={{ fontWeight: 600, textTransform: 'capitalize' }}>{tier.name}</td>
|
|
<td>
|
|
<input
|
|
type="number" step="0.5" style={{ width: 80 }}
|
|
value={tier.peak_above_c}
|
|
onChange={e => updateTier(i, 'peak_above_c', parseFloat(e.target.value) || 0)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<select value={tier.power} onChange={e => updateTier(i, 'power', e.target.value)}>
|
|
<option value="on">On</option>
|
|
<option value="off">Off</option>
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<select value={tier.mode} onChange={e => updateTier(i, 'mode', e.target.value)}>
|
|
{MHI_MODES.map(m => <option key={m} value={m}>{m[0].toUpperCase() + m.slice(1)}</option>)}
|
|
</select>
|
|
</td>
|
|
<td>
|
|
<input
|
|
type="number" step="0.5" style={{ width: 90 }}
|
|
value={tier.setpoint}
|
|
onChange={e => updateTier(i, 'setpoint', parseFloat(e.target.value) || 0)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<select value={tier.fan_speed} onChange={e => updateTier(i, 'fan_speed', e.target.value)}>
|
|
{MHI_FAN_SPEEDS.map(f => <option key={f} value={f}>{f[0].toUpperCase() + f.slice(1)}</option>)}
|
|
</select>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div style={{ fontWeight: 600, fontSize: 13, margin: '10px 0 6px' }}>Default TRVs</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Occupied setpoint (°C)</label>
|
|
<input
|
|
type="number" step="0.5" value={defaults.trv_occupied_temp}
|
|
onChange={e => setDefaults(d => ({ ...d, trv_occupied_temp: parseFloat(e.target.value) || 0 }))}
|
|
/>
|
|
</div>
|
|
<div className="field">
|
|
<label>Vacant setpoint (°C)</label>
|
|
<input
|
|
type="number" step="0.5" value={defaults.trv_vacant_temp}
|
|
onChange={e => setDefaults(d => ({ ...d, trv_vacant_temp: parseFloat(e.target.value) || 0 }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={{ marginBottom: 24 }}>
|
|
<button className="btn btn-primary" disabled={defaultsSaving} onClick={saveDefaults}>
|
|
{defaultsSaving ? 'Saving…' : 'Save Default Templates'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="section-title">Daikin Aircon</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Daikin units are WiFi/DHCP, so their IP changes over time. Instead of a fixed IP, hvac
|
|
periodically sweeps this LAN range over HTTP and matches units by their MAC address —
|
|
set it once and it self-heals if a unit's IP changes. Run "Discover Daikin Split" on the
|
|
Devices page after saving to find units for the first time.
|
|
</p>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Daikin subnet (CIDR)</label>
|
|
<input type="text" value={daikinCidr} onChange={e => setDaikinCidr(e.target.value)} placeholder="10.4.0.0/22" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section-title">Maintenance Integration</div>
|
|
<p className="field-hint" style={{ marginBottom: 10 }}>
|
|
Not yet available — maintenance needs its own Settings → API Keys page first.
|
|
Once that lands, generate a key there and paste it here to enable "Create asset in Maintenance" on the Devices page.
|
|
</p>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>Maintenance URL</label>
|
|
<input type="text" value={maintUrl} onChange={e => setMaintUrl(e.target.value)} placeholder="https://hotel.example.com/maintenance" />
|
|
</div>
|
|
<div className="field">
|
|
<label>Maintenance API key</label>
|
|
<input type="text" value={maintKey} onChange={e => setMaintKey(e.target.value)} placeholder="paste key" />
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 24 }}>
|
|
<button className="btn btn-primary" disabled={configSaving} onClick={saveGeneralConfig}>
|
|
{configSaving ? 'Saving…' : 'Save Settings'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|