// frontend/src/pages/bellcloud/devices/OnboardDevice.jsx // Onboard a manufactured/verified device: assign customer + users, set plan, configure. // Device selection is restricted to devices past the "verify" stage (provisioned, sold, claimed). import { useState, useEffect, useCallback, useRef, useMemo, useImperativeHandle, forwardRef } from 'react' import { createPortal } from 'react-dom' import { useSearchParams } from 'react-router-dom' import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import api from '@/lib/api' import { useAuth } from '@/hooks/useAuth' import { useToast } from '@/components/ui/Toast' import { useDeviceCommand } from '@/hooks/useDeviceCommand' import { fmtDateMedium, fmtRelative } from '@/lib/formatters' import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import Card from '@/components/ui/Card' import DataTable from '@/components/ui/DataTable' import SearchBar from '@/components/ui/SearchBar' import StatusBadge from '@/components/ui/StatusBadge' import SegmentedControl from '@/components/ui/SegmentedControl' import MultiSelect from '@/components/ui/MultiSelect' import FormField from '@/components/ui/FormField' import Spinner from '@/components/ui/Spinner' import Icon from '@/components/ui/Icon' import Tabs from '@/components/ui/Tabs' import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal' import AssignCustomerModal from '@/modals/bellcloud/devices/AssignCustomerModal' import CreateUserModal from '@/modals/bellcloud/users/CreateUserModal' import CreateCustomerModal from '@/modals/crm/customers/CreateCustomerModal' import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal' // ─── Constants ──────────────────────────────────────────────────────────────── const ONBOARDABLE_STATUSES = ['provisioned', 'sold', 'claimed'] const STATUS_VARIANT = { manufactured: 'neutral', flashed: 'info', provisioned: 'warning', sold: 'success', claimed: 'success', decommissioned: 'danger', } const TIERS = ['basic', 'small', 'mini', 'premium', 'vip', 'custom'] // ─── Configure step constants (mirrors DeviceDetail's tabs/modals) ─────────── const LOG_LEVEL_META = { 0: { label: 'Disabled', color: 'var(--color-text-muted)' }, 1: { label: 'Error', color: 'var(--color-danger)' }, 2: { label: 'Warning', color: 'var(--color-warning)' }, 3: { label: 'Info', color: 'var(--color-info)' }, 4: { label: 'Debug', color: '#9f7aea' }, 5: { label: 'Verbose', color: 'var(--color-success)' }, } const LOG_CMD = { serial: 'log.set_serial', sd: 'log.set_sd', mqtt: 'log.set_mqtt' } const LOG_ATTR = { serial: 'serialLogLevel', sd: 'sdLogLevel', mqtt: 'mqttLogLevel' } const TIMING_OPTIONS = [80, 85, 90, 95, 100, 110, 120, 140] const TIMING_LABELS = { 80: 'Min — 80ms', 85: 'Size 1 — 85ms', 90: 'Size 2 — 90ms', 95: 'Size 3 — 95ms', 100: 'Size 4 — 100ms', 110: 'Size 5 — 110ms', 120: 'Size 6 — 120ms', 140: 'Max — 140ms', } const PULSE_STEPS = [200, 300, 400, 500, 600, 800, 1000, 1200, 1500, 2000] const PAUSE_STEPS = [500, 1000, 1500, 2000, 3000, 4000, 5000] // Alert tempo is a raw millisecond int in Firestore — use a continuous // 100ms-step range rather than snapping to a handful of preset values. const TEMPO_MIN = 500 const TEMPO_MAX = 2500 const TEMPO_STEP = 100 function msLabel(ms) { if (!ms) return '—' return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms` } const ALERT_TYPE_TO_FIRMWARE = { disabled: 'OFF', single: 'SINGLE', multi: 'HOURS' } const toFirmwareBell = (n) => (n === 0 ? 255 : n) // Mirrors the backend's recursive per-field merge (backend/devices/service.py // _deep_merge) — needed client-side only when combining multiple groups' // device_attributes patches into a single PUT (the offline/save-only path). function deepMerge(base, overrides) { const result = { ...base } for (const [k, v] of Object.entries(overrides)) { result[k] = (v && typeof v === 'object' && !Array.isArray(v) && result[k] && typeof result[k] === 'object' && !Array.isArray(result[k])) ? deepMerge(result[k], v) : v } return result } function timeToIso(hhmm) { if (!hhmm) return '' return `2000-01-01T${hhmm}:00Z` } function extractTime(tsStr) { if (!tsStr) return '' const d = new Date(tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')) if (!isNaN(d.getTime())) { return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}` } const match = tsStr.match(/(\d{1,2}):(\d{2})/) return match ? `${match[1].padStart(2, '0')}:${match[2]}` : '' } const BOARD_TYPE_OPTIONS = [ { value: 'vesper', label: 'Vesper' }, { value: 'vesper_plus', label: 'Vesper Plus' }, { value: 'vesper_pro', label: 'Vesper Pro' }, { value: 'chronos', label: 'Chronos' }, { value: 'chronos_pro', label: 'Chronos Pro' }, { value: 'agnus_mini', label: 'Agnus Mini' }, { value: 'agnus', label: 'Agnus' }, ] const USERS_FILTER_OPTIONS = [ { value: 'all', label: 'All Devices' }, { value: 'no-users', label: 'No Users' }, ] const STEPS = [ { key: 'device', label: 'Select Device' }, { key: 'assign', label: 'Assign' }, { key: 'plan', label: 'Plan & Warranty' }, { key: 'configure', label: 'Configure' }, { key: 'finalize', label: 'Finalize' }, ] function friendlyName(d) { return d.console_name || d.device_name || null } // ─── Step indicator ─────────────────────────────────────────────────────────── function StepIndicator({ steps, activeIndex }) { return (
{steps.map((step, i) => { const done = i < activeIndex const active = i === activeIndex return (
{done ? ( ) : i + 1}
{step.label}
{i < steps.length - 1 && (
)}
) })}
) } // ─── Users hover popover cell ───────────────────────────────────────────────── function UsersHoverCell({ users }) { const [open, setOpen] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const anchorRef = useRef(null) const closeTimer = useRef(null) const count = users?.length || 0 function handleEnter() { clearTimeout(closeTimer.current) if (anchorRef.current) { const r = anchorRef.current.getBoundingClientRect() setPos({ top: r.bottom + 6, left: r.left }) } setOpen(true) } function handleLeave() { closeTimer.current = setTimeout(() => setOpen(false), 100) } if (count === 0) { return 0 } return ( {count} {open && createPortal(

Assigned Users

{users.map((u) => (
{(u.display_name || u.email || '?')[0].toUpperCase()}
{u.display_name || u.email || u.user_id}
))}
, document.body )}
) } // ─── Device picker ──────────────────────────────────────────────────────────── const TABLE_COLUMNS = [ { key: 'serial_number', label: 'Serial Number', sortable: true, render: (d) => {d.serial_number}, }, { key: 'friendly_name', label: 'Friendly Name', render: (d) => friendlyName(d) || —, }, { key: 'hw_type', label: 'Board Type', sortable: true, render: (d) => d.hw_type || —, }, { key: 'mfg_status', label: 'Status', render: (d) => {d.mfg_status}, }, { key: 'customer_id', label: 'Customer', render: (d) => d.customer_id ? {d.customer_name || 'Assigned'} : Unassigned, }, { key: 'user_list', label: 'Users', render: (d) => , }, ] function DevicePicker({ onSelect }) { const [search, setSearch] = useState('') const [usersFilter, setUsersFilter] = useState('all') const [boardTypes, setBoardTypes] = useState([]) const [devices, setDevices] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') // Pull the full onboardable list once — filters/search below are applied // client-side against this cached list instead of re-fetching per change. const load = useCallback(async () => { setLoading(true); setError('') try { const data = await api.get('/manufacturing/devices?limit=200') const all = data.devices || [] setDevices(all.filter((d) => ONBOARDABLE_STATUSES.includes(d.mfg_status))) } catch (err) { setError(err.message) } finally { setLoading(false) } }, []) useEffect(() => { load() }, [load]) const filtered = useMemo(() => { let list = devices if (usersFilter === 'no-users') { list = list.filter((d) => !(d.user_list?.length)) } if (boardTypes.length > 0) { const boardTypeSet = new Set(boardTypes) list = list.filter((d) => boardTypeSet.has(d.hw_type)) } const q = search.trim().toLowerCase() if (q) { list = list.filter((d) => { if ((d.serial_number || '').toLowerCase().includes(q)) return true if ((friendlyName(d) || '').toLowerCase().includes(q)) return true if ((d.hw_type || '').toLowerCase().includes(q)) return true if ((d.customer_name || '').toLowerCase().includes(q)) return true if ((d.users || []).some((u) => (u.display_name || '').toLowerCase().includes(q) || (u.email || '').toLowerCase().includes(q))) return true return false }) } return list }, [devices, search, usersFilter, boardTypes]) const hasActiveFilters = usersFilter !== 'all' || boardTypes.length > 0 return (
{hasActiveFilters && ( )}
{error && (
{error}
)} onSelect(d)} skeletonRows={6} />
) } // ─── Customer tab ───────────────────────────────────────────────────────────── function CustomerTab({ device, onDeviceUpdated }) { const { toast } = useToast() const [assigning, setAssigning] = useState(false) const [assignedCustomer, setAssignedCustomer] = useState(null) const [loadingCustomer, setLoadingCustomer] = useState(false) const [showAssign, setShowAssign] = useState(false) const [showCreate, setShowCreate] = useState(false) useEffect(() => { if (!device.customer_id) { setAssignedCustomer(null); return } setLoadingCustomer(true) api.get(`/manufacturing/customers/${device.customer_id}`) .then(setAssignedCustomer) .catch(() => setAssignedCustomer(null)) .finally(() => setLoadingCustomer(false)) }, [device.customer_id]) const assignCustomer = async (customer) => { setAssigning(true) try { const updated = await api.request(`/manufacturing/devices/${device.serial_number}/assign`, { method: 'POST', body: JSON.stringify({ customer_id: customer.id }), }) setAssignedCustomer(customer) onDeviceUpdated(updated) toast.success('Customer assigned', `${[customer.name, customer.surname].filter(Boolean).join(' ') || customer.email} assigned to this device.`) } catch (err) { toast.danger('Error', err.message || 'Failed to assign customer.') } finally { setAssigning(false) } } const handleAssignSelected = async (customer) => { setShowAssign(false) await assignCustomer(customer) } const handleCustomerCreated = async (customer) => { setShowCreate(false) toast.success('Customer created', `${[customer.name, customer.surname].filter(Boolean).join(' ')} was created.`) await assignCustomer(customer) } return (

Assign this device to the customer that ordered it.

{loadingCustomer ? (
) : assignedCustomer ? (
{(assignedCustomer.name || '?')[0].toUpperCase()}

{[assignedCustomer.name, assignedCustomer.surname].filter(Boolean).join(' ') || assignedCustomer.email}

{assignedCustomer.organization &&

{assignedCustomer.organization}

}
Assigned
) : (

No customer assigned yet.

)} setShowAssign(false)} onSelect={handleAssignSelected} deviceId={device.id} /> setShowCreate(false)} onCreated={handleCustomerCreated} /> {assigning && (
)}
) } // ─── Users tab ──────────────────────────────────────────────────────────────── function UsersTab({ device }) { const { toast } = useToast() const [users, setUsers] = useState([]) const [loading, setLoading] = useState(false) const [showAddUser, setShowAddUser] = useState(false) const [showCreateUser, setShowCreateUser] = useState(false) const [removingUser, setRemovingUser] = useState(null) const loadUsers = useCallback(async () => { setLoading(true) try { const data = await api.get(`/devices/${device.id}/users`) setUsers(data.users || []) } catch { setUsers([]) } finally { setLoading(false) } }, [device.id]) useEffect(() => { loadUsers() }, [loadUsers]) const existingUserIds = users.map((u) => u.user_id).filter(Boolean) const handleUserAdded = (user) => { setUsers((prev) => [...prev, { user_id: user.id, display_name: user.display_name, email: user.email, photo_url: user.photo_url, role: '' }]) toast.success('User added', `${user.display_name || user.email} now has access.`) } const handleUserCreated = async (user) => { setShowCreateUser(false) try { await api.post(`/devices/${device.id}/user-list`, { user_id: user.id }) setUsers((prev) => [...prev, { user_id: user.id, display_name: user.display_name, email: user.email, photo_url: user.photo_url, role: '' }]) toast.success('User created', `${user.display_name || user.email} was created and given access.`) } catch (err) { toast.danger('Error', err.message || 'User was created but could not be assigned to this device.') } } const handleRemoveUser = async (user) => { setRemovingUser(user.user_id) try { await api.delete(`/devices/${device.id}/user-list/${user.user_id}`) setUsers((prev) => prev.filter((u) => u.user_id !== user.user_id)) toast.success('Removed', `${user.display_name || user.email} removed.`) } catch (err) { toast.danger('Error', err.message || 'Failed to remove user.') } finally { setRemovingUser(null) } } return (

Grant app-user accounts access to view and control this device.

{loading ? (
) : users.length === 0 ? (

No users assigned yet.

) : (
{users.map((u) => (
{(u.display_name || u.email || '?')[0].toUpperCase()}

{u.display_name || u.email}

{u.email && u.display_name &&

{u.email}

}
))}
)} setShowAddUser(false)} onAdded={handleUserAdded} /> setShowCreateUser(false)} onCreated={handleUserCreated} />
) } // ─── Plan tab (subscription + warranty) ────────────────────────────────────── function parseFirestoreDate(val) { if (!val) return null if (typeof val === 'object' && typeof val.seconds === 'number') return new Date(val.seconds * 1000) const cleaned = String(val).replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC') const d = new Date(cleaned) return isNaN(d.getTime()) ? null : d } const PlanStep = forwardRef(function PlanStep({ device, onDeviceUpdated }, ref) { const [loading, setLoading] = useState(true) const [tier, setTier] = useState('basic') const [startDateStr, setStartDateStr] = useState(() => new Date().toISOString().split('T')[0]) const [duration, setDuration] = useState(365) const [maxUsers, setMaxUsers] = useState('1') const [maxOutputs, setMaxOutputs] = useState('1') const [warrantyPeriod, setWarrantyPeriod] = useState(365) // Onboarding can also be used to reconfigure an already-deployed device, so // always pull the current subscription/warranty from the device doc rather // than trusting the wizard's cached device object (which, when it came from // the /manufacturing/devices list, doesn't carry these fields at all). const loadPlan = useCallback(async () => { setLoading(true) try { const d = await api.get(`/devices/${device.id}`) const sub = d.device_subscription || {} const stats = d.device_stats || {} setTier(sub.subscrTier || 'basic') const startDate = parseFirestoreDate(sub.subscrStart) || parseFirestoreDate(stats.warrantyStart) setStartDateStr(startDate ? startDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]) setDuration(sub.subscrDuration || 365) setMaxUsers(String(sub.maxUsers || 1)) setMaxOutputs(String(sub.maxOutputs || 1)) setWarrantyPeriod(stats.warrantyPeriod || 365) } finally { setLoading(false) } }, [device.id]) useEffect(() => { loadPlan() }, [loadPlan]) const save = useCallback(async () => { const startIso = startDateStr ? `${startDateStr}T00:00:00Z` : new Date().toISOString() // PUT /devices/{id} returns the DeviceInDB shape, which doesn't line up // field-for-field with the DeviceInventoryItem shape the rest of the wizard // uses (e.g. hw_type vs hw_family) — merge in just the two fields Configure // actually needs rather than replacing the whole device object. const updated = await api.put(`/devices/${device.id}`, { device_subscription: { subscrTier: tier, subscrStart: startIso, subscrDuration: duration, maxUsers: Number(maxUsers), maxOutputs: Number(maxOutputs), }, device_stats: { warrantyActive: true, warrantyStart: startIso, warrantyPeriod, }, }) onDeviceUpdated?.((prev) => ({ ...prev, device_subscription: updated.device_subscription, device_stats: updated.device_stats, })) }, [device.id, tier, startDateStr, duration, maxUsers, maxOutputs, warrantyPeriod, onDeviceUpdated]) useImperativeHandle(ref, () => ({ save }), [save]) if (loading) { return
} return (

Set the subscription plan and warranty period for this device. Start date applies to both. These are saved when you continue to the next step.

setTier(e.target.value)}> {TIERS.map((t) => )} setStartDateStr(e.target.value)} /> setDuration(Math.max(0, parseInt(e.target.value) || 0))} inputProps={{ min: 0 }} /> setMaxUsers(e.target.value)}> {Array.from({ length: 10 }, (_, i) => i + 1).map((n) => )} setMaxOutputs(e.target.value)}> {Array.from({ length: 16 }, (_, i) => i + 1).map((n) => )} setWarrantyPeriod(Math.max(0, parseInt(e.target.value) || 0))} inputProps={{ min: 0 }} />
) }) // ─── Configure step — Log tab ───────────────────────────────────────────────── function LogConfigTab({ values, onChange }) { return (

Set the logging verbosity for each output. 0 = Disabled, 5 = Verbose.

{['serial', 'sd', 'mqtt'].map((field) => { const level = values[field] const meta = LOG_LEVEL_META[level] return (
{field} Log {meta?.label || '—'}
onChange({ ...values, [field]: Number(e.target.value) })} style={{ width: '100%', accentColor: meta?.color || 'var(--color-primary)', cursor: 'pointer' }} />
) })}
) } // ─── Configure step — Bell tab ──────────────────────────────────────────────── function BellConfigTab({ values, onChange, maxOutputs, reservedOutputs = [] }) { const { masterOn, activeBells, rows } = values const updateRow = (i, field, val) => { onChange({ ...values, rows: rows.map((r, idx) => idx === i ? { ...r, [field]: Number(val) } : r) }) } const activeRows = rows.slice(0, activeBells) const reservedSet = new Set(reservedOutputs) return (

Bell Master Control

Enable or disable the bell mechanism entirely

Active Bells: {activeBells}

{Array.from({ length: maxOutputs }, (_, i) => { const n = i + 1 const isActive = n <= activeBells return ( ) })}
{activeBells > 0 && (
BellOutputTiming
{activeRows.map((row, i) => (
Bell {i + 1} updateRow(i, 'output', e.target.value)}> {Array.from({ length: maxOutputs }, (_, j) => { const out = j + 1 const reserved = reservedSet.has(out) && out !== row.output return })} updateRow(i, 'timing', e.target.value)}> {TIMING_OPTIONS.map((ms) => )}
))}
)}
) } // ─── Configure step — Clock tab ─────────────────────────────────────────────── function StepSlider({ steps, value, onChange, label }) { const idx = steps.indexOf(value) const currentIdx = idx >= 0 ? idx : 0 return (
{label} {msLabel(steps[currentIdx])}
onChange(steps[Number(e.target.value)])} style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }} />
{msLabel(steps[0])} {msLabel(steps[steps.length - 1])}
) } function ClockConfigTab({ values, onChange, maxOutputs, reservedOutputs = [], bellOutputsUsed = [] }) { const { masterOn, out1, out2, pulse, pause } = values const conflictMsg = out1 > 0 && out1 === out2 ? 'ODD and EVEN outputs cannot be the same.' : null const validationMsg = masterOn && (out1 === 0 || out2 === 0) ? 'Both outputs must be assigned when the clock is enabled.' : null const reservedSet = new Set(reservedOutputs) const outputOptions = Array.from({ length: maxOutputs }, (_, j) => { const out = j + 1 return { out, isBell: bellOutputsUsed.includes(out), reserved: reservedSet.has(out) } }) return (

Clock Master Control

Enable or disable the clock mechanism entirely

onChange({ ...values, out1: Number(e.target.value) })}> {outputOptions.map(({ out, isBell, reserved }) => { const disabled = out !== out1 && (reserved || out === out2) return })} onChange({ ...values, out2: Number(e.target.value) })}> {outputOptions.map(({ out, isBell, reserved }) => { const disabled = out !== out2 && (reserved || out === out1) return })}
onChange({ ...values, pulse: v })} /> onChange({ ...values, pause: v })} /> {(conflictMsg || validationMsg) && (

{conflictMsg || validationMsg}

)}
) } // ─── Configure step — Alert tab ─────────────────────────────────────────────── function AlertTypeSelector({ value, onChange }) { const options = [ { value: 'disabled', label: 'Disabled' }, { value: 'single', label: 'Single Fire' }, { value: 'multi', label: 'Hour Indicating' }, ] return (
{options.map((opt, i) => ( ))}
) } function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChange }) { return (
{title}
{isOn && (
onFromChange(e.target.value)} placeholder="HH:MM" /> onToChange(e.target.value)} placeholder="HH:MM" />
)}
) } function AlertConfigTab({ values, onChange, connectedBellIndexes }) { const { alertType, tempo, hourBell, halfBell, quarterBell, isDayOn, dayFrom, dayTo, isNightOn, nightFrom, nightTo } = values const bellOptions = [{ value: '0', label: 'Disabled' }, ...connectedBellIndexes.map((n) => ({ value: String(n), label: `Bell ${n}` }))] const validationMsg = (() => { if (alertType === 'multi' && !tempo) return 'Alert Tempo must be set for Hour Indicating mode.' if (isDayOn && (!dayFrom || !dayTo)) return 'Day Silence is on — set both From and Until times.' if (isNightOn && (!nightFrom || !nightTo)) return 'Night Silence is on — set both From and Until times.' return null })() return (
Alert Type onChange({ ...values, alertType: v })} />
{alertType === 'multi' && (
Alert Tempo {msLabel(tempo || 1000)}
onChange({ ...values, tempo: Number(e.target.value) })} style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }} />
{msLabel(TEMPO_MIN)} {msLabel(TEMPO_MAX)}
)}
onChange({ ...values, hourBell: e.target.value })}> {bellOptions.map((o) => )} onChange({ ...values, halfBell: e.target.value })}> {bellOptions.map((o) => )} onChange({ ...values, quarterBell: e.target.value })}> {bellOptions.map((o) => )}
onChange({ ...values, isDayOn: !isDayOn })} from={dayFrom} onFromChange={(v) => onChange({ ...values, dayFrom: v })} to={dayTo} onToChange={(v) => onChange({ ...values, dayTo: v })} /> onChange({ ...values, isNightOn: !isNightOn })} from={nightFrom} onFromChange={(v) => onChange({ ...values, nightFrom: v })} to={nightTo} onToChange={(v) => onChange({ ...values, nightTo: v })} /> {validationMsg && (

{validationMsg}

)}
) } // ─── Configure step — Backlight tab ─────────────────────────────────────────── function BacklightConfigTab({ values, onChange, maxOutputs, reservedOutputs = [], bellOutputsUsed = [], clockOutputsUsed = [] }) { const { enabled, output, onTime, offTime } = values const reservedSet = new Set(reservedOutputs) const validationMsg = (() => { if (!enabled) return null if (!output || output === 0) return 'An output must be assigned when backlight automation is enabled.' if (!onTime || !offTime) return 'Both Turn On and Turn Off times must be set.' return null })() return (
onChange({ ...values, enabled: e.target.value === 'true' })}> onChange({ ...values, output: Number(e.target.value) })}> {Array.from({ length: maxOutputs }, (_, j) => { const out = j + 1 const isBell = bellOutputsUsed.includes(out) const isClock = clockOutputsUsed.includes(out) const reserved = reservedSet.has(out) && out !== output return })} {enabled && (
onChange({ ...values, onTime: e.target.value })} placeholder="HH:MM" /> onChange({ ...values, offTime: e.target.value })} placeholder="HH:MM" />
)} {validationMsg && (

{validationMsg}

)}
) } // ─── Configure step — command sequence progress ────────────────────────────── // Idle = neutral grey, running = soft blue, success = soft green, failed = soft amber. const SEQUENCE_CARD_STYLE = { pending: { bg: 'var(--color-bg-elevated)', border: 'var(--color-border)' }, running: { bg: 'var(--color-info-bg)', border: 'var(--color-info)' }, success: { bg: 'var(--color-success-bg)', border: 'var(--color-success)' }, failed: { bg: 'var(--color-warning-bg)', border: 'var(--color-warning)' }, } function SequenceProgress({ groups, status }) { // status: { [groupKey]: 'pending' | 'running' | 'success' | 'failed' } return (
{groups.map((g) => { const st = status[g.key] || 'pending' const cardStyle = SEQUENCE_CARD_STYLE[st] const icon = st === 'success' ? { name: 'check', color: 'var(--color-success)' } : st === 'failed' ? { name: 'close', color: 'var(--color-warning)' } : st === 'running' ? null : null return (
{st === 'running' ? : icon ? : (
)}
{g.label} {st}
) })}
) } // ─── Configure step ─────────────────────────────────────────────────────────── const CONFIGURE_TABS = [ { key: 'log', label: 'Logging' }, { key: 'bell', label: 'Bell' }, { key: 'clock', label: 'Clock' }, { key: 'alert', label: 'Alerts' }, { key: 'backlight', label: 'Backlight' }, ] // Builds the five settings-group form states from a device_attributes object // (either freshly fetched from the DB, or defaults when there's nothing saved yet). function buildConfigFormState(attr, maxOutputs) { const clock = attr.clockSettings || {} const existingOutputs = attr.bellOutputs || [] const existingTimings = attr.hammerTimings || [] const existingClockOutputs = clock.clockOutputs || [] const existingClockTimings = clock.clockTimings || [] return { log: { serial: attr.serialLogLevel ?? 0, sd: attr.sdLogLevel ?? 0, mqtt: attr.mqttLogLevel ?? 0, }, bell: { masterOn: attr.hasBells ?? false, activeBells: attr.totalBells || 0, rows: Array.from({ length: maxOutputs }, (_, i) => ({ output: (existingOutputs[i] ?? 0) === 65535 ? 0 : (existingOutputs[i] ?? 0), timing: existingTimings[i] ?? 90, })), }, clockForm: { masterOn: attr.hasClock ?? false, out1: existingClockOutputs[0] ?? 0, out2: existingClockOutputs[1] ?? 0, pulse: existingClockTimings[0] || PULSE_STEPS[3], pause: existingClockTimings[1] || PAUSE_STEPS[1], }, alert: { alertType: clock.ringAlerts || 'disabled', // clock.ringIntervals can be 0/unset when never configured — `||` (not // `??`) so a falsy 0 also falls back to a real default, matching the // slider's own visible position instead of silently desyncing from it. tempo: clock.ringIntervals || 1000, hourBell: String(clock.hourAlertsBell ?? 0), halfBell: String(clock.halfhourAlertsBell ?? 0), quarterBell: String(clock.quarterAlertsBell ?? 0), isDayOn: clock.isDaySilenceOn ?? false, dayFrom: extractTime(clock.daySilenceFrom), dayTo: extractTime(clock.daySilenceTo), isNightOn: clock.isNightSilenceOn ?? false, nightFrom: extractTime(clock.nightSilenceFrom), nightTo: extractTime(clock.nightSilenceTo), }, backlight: { enabled: clock.isBacklightAutomationOn ?? false, output: clock.backlightOutput ?? 0, onTime: extractTime(clock.backlightTurnOnTime), offTime: extractTime(clock.backlightTurnOffTime), }, } } function ConfigureStep({ device, onDeviceUpdated, onNext, onBack }) { const { toast } = useToast() const deviceSerial = device.serial_number const maxOutputs = device?.device_subscription?.maxOutputs || 8 const [subTab, setSubTab] = useState('log') const [loading, setLoading] = useState(true) const [isOnline, setIsOnline] = useState(false) const [mode, setMode] = useState(null) // null (choosing) | 'push' | 'skip' | 'save-only' const [clock, setClock] = useState({}) // ── Form state for each settings group — seeded with defaults, then // replaced with the device's actual current attributes once loaded below. // These come from a plain DB read (GET /devices/{id}), not a live device // poll — the wizard's cached device object (DeviceInventoryItem, from the // /manufacturing/devices list) never carries device_attributes at all. const [log, setLog] = useState({ serial: 0, sd: 0, mqtt: 0 }) const [bell, setBell] = useState({ masterOn: false, activeBells: 0, rows: [] }) const [clockForm, setClockForm] = useState({ masterOn: false, out1: 0, out2: 0, pulse: PULSE_STEPS[3], pause: PAUSE_STEPS[1] }) const [alert, setAlert] = useState({ alertType: 'disabled', tempo: 1000, hourBell: '0', halfBell: '0', quarterBell: '0', isDayOn: false, dayFrom: '', dayTo: '', isNightOn: false, nightFrom: '', nightTo: '', }) const [backlight, setBacklight] = useState({ enabled: false, output: 0, onTime: '', offTime: '' }) // Fetch the device's saved attributes and its live online status together // (rather than serially) so the step doesn't sit on a spinner any longer // than the slower of the two calls. const loadConfig = useCallback(async () => { setLoading(true) try { const [deviceDoc, mqttStatus] = await Promise.all([ api.get(`/devices/${device.id}`), api.get('/mqtt/status').catch(() => null), ]) const attr = deviceDoc.device_attributes || {} const state = buildConfigFormState(attr, maxOutputs) setClock(attr.clockSettings || {}) setLog(state.log) setBell(state.bell) setClockForm(state.clockForm) setAlert(state.alert) setBacklight(state.backlight) const match = (mqttStatus?.devices || []).find((d) => d.device_serial === deviceSerial) setIsOnline(!!match?.online) } finally { setLoading(false) } }, [device.id, deviceSerial, maxOutputs]) useEffect(() => { loadConfig() }, [loadConfig]) // Always bind to the real deviceSerial (not gated on `mode`) — gating it // meant the very first push after clicking "Push Settings to Device" used // a stale closure from the render where mode was still null, so the first // command always failed instantly with "No device id." before the // useDeviceCommand hook had a chance to re-render with the real serial. const { sendMqttCommandSilent: sendSilently } = useDeviceCommand({ deviceSerial }) // Outputs are exclusive across Bell / Clock / Backlight — each group can // only see its own current picks plus whatever's still free; anything // claimed by one of the other two groups is shown disabled everywhere else. const bellOutputsUsed = bell.rows.slice(0, bell.activeBells).map((r) => r.output).filter((o) => o > 0) const connectedBellIndexes = bell.rows.slice(0, bell.activeBells) .map((r, i) => ({ output: r.output, bellNumber: i + 1 })) .filter(({ output }) => output > 0) .map(({ bellNumber }) => bellNumber) const clockOutputsUsed = [clockForm.out1, clockForm.out2].filter((o) => o > 0) const backlightOutputUsed = backlight.output > 0 ? [backlight.output] : [] // ── Build the command sequence + Firestore patch for each group ───────── const buildGroups = useCallback(() => { const activeBellRows = bell.rows.slice(0, bell.activeBells) // relay.set_config is a partial update — any bell index NOT present in // these maps is left untouched by the firmware. Send every row (not just // the active ones) so anything beyond activeBells is explicitly zeroed — // same fix as EditBellOutputsModal. const durations = {} const outputs = {} bell.rows.forEach((r, i) => { durations[i] = r.timing; outputs[i] = i < bell.activeBells ? r.output : 0 }) return [ { key: 'log', label: 'Logging Settings', commands: [ { cmd: LOG_CMD.serial, contents: { level: log.serial } }, { cmd: LOG_CMD.sd, contents: { level: log.sd } }, { cmd: LOG_CMD.mqtt, contents: { level: log.mqtt } }, ], firestorePatch: { device_attributes: { [LOG_ATTR.serial]: log.serial, [LOG_ATTR.sd]: log.sd, [LOG_ATTR.mqtt]: log.mqtt, }, }, }, { key: 'bell', label: 'Bell Settings', commands: [ { cmd: bell.masterOn ? 'bells.enable' : 'bells.disable', contents: {} }, ...(bell.activeBells > 0 ? [{ cmd: 'relay.set_config', contents: { durations, outputs } }] : []), ], firestorePatch: { device_attributes: { hasBells: bell.masterOn, totalBells: bell.activeBells, bellOutputs: activeBellRows.map((r) => r.output), hammerTimings: activeBellRows.map((r) => r.timing), }, }, }, { key: 'clock', label: 'Clock Settings', commands: [ { cmd: 'clock.set_config', contents: { enabled: clockForm.masterOn, c1: clockForm.out1, c2: clockForm.out2, pulse_duration: clockForm.pulse, pause_duration: clockForm.pause } }, ], firestorePatch: { device_attributes: { hasClock: clockForm.masterOn, clockSettings: { clockOutputs: [clockForm.out1, clockForm.out2].filter((o) => o > 0), clockTimings: [clockForm.pulse, clockForm.pause], }, }, }, }, { key: 'alert', label: 'Alert Settings', commands: [ { cmd: 'clock.set_alerts_config', contents: { alert_type: ALERT_TYPE_TO_FIRMWARE[alert.alertType], alert_interval: alert.alertType === 'multi' ? alert.tempo : undefined, hour_bell: toFirmwareBell(Number(alert.hourBell)), half_bell: toFirmwareBell(Number(alert.halfBell)), quarter_bell: toFirmwareBell(Number(alert.quarterBell)), daytime_silence: alert.isDayOn, daytime_on: alert.isDayOn ? alert.dayFrom : undefined, daytime_off: alert.isDayOn ? alert.dayTo : undefined, night_silence: alert.isNightOn, night_on: alert.isNightOn ? alert.nightFrom : undefined, night_off: alert.isNightOn ? alert.nightTo : undefined, } }, ], firestorePatch: { device_attributes: { clockSettings: { ringAlerts: alert.alertType, ringIntervals: alert.alertType === 'multi' ? alert.tempo : clock.ringIntervals, hourAlertsBell: Number(alert.hourBell), halfhourAlertsBell: Number(alert.halfBell), quarterAlertsBell: Number(alert.quarterBell), isDaySilenceOn: alert.isDayOn, daySilenceFrom: alert.isDayOn && alert.dayFrom ? timeToIso(alert.dayFrom) : (clock.daySilenceFrom || ''), daySilenceTo: alert.isDayOn && alert.dayTo ? timeToIso(alert.dayTo) : (clock.daySilenceTo || ''), isNightSilenceOn: alert.isNightOn, nightSilenceFrom: alert.isNightOn && alert.nightFrom ? timeToIso(alert.nightFrom) : (clock.nightSilenceFrom || ''), nightSilenceTo: alert.isNightOn && alert.nightTo ? timeToIso(alert.nightTo) : (clock.nightSilenceTo || ''), }, }, }, }, { key: 'backlight', label: 'Backlight Settings', commands: [ { cmd: 'clock.set_backlight', contents: { backlight: backlight.enabled, backlight_output: Number(backlight.output), backlight_on: backlight.enabled ? backlight.onTime : undefined, backlight_off: backlight.enabled ? backlight.offTime : undefined, } }, ], firestorePatch: { device_attributes: { clockSettings: { isBacklightAutomationOn: backlight.enabled, backlightOutput: Number(backlight.output), backlightTurnOnTime: backlight.enabled && backlight.onTime ? timeToIso(backlight.onTime) : '', backlightTurnOffTime: backlight.enabled && backlight.offTime ? timeToIso(backlight.offTime) : '', }, }, }, }, ] }, [log, bell, clockForm, alert, backlight, clock]) // ── Sequencer ───────────────────────────────────────────────────────── const [sequencing, setSequencing] = useState(false) const [groupStatus, setGroupStatus] = useState({}) const [failedGroup, setFailedGroup] = useState(null) // { group, attempt } const groupsRef = useRef(buildGroups()) const runGroup = useCallback(async (group) => { setGroupStatus((prev) => ({ ...prev, [group.key]: 'running' })) let lastError = null for (let attempt = 1; attempt <= 3; attempt++) { try { for (const { cmd, contents } of group.commands) { await sendSilently(cmd, contents) } // Device confirmed every command in this group — persist to Firestore. await api.put(`/devices/${device.id}`, group.firestorePatch) setGroupStatus((prev) => ({ ...prev, [group.key]: 'success' })) return true } catch (err) { lastError = err } } setGroupStatus((prev) => ({ ...prev, [group.key]: 'failed' })) setFailedGroup({ group, error: lastError }) return false }, [sendSilently, device.id]) const runSequence = useCallback(async (groups) => { setSequencing(true) setFailedGroup(null) for (const group of groups) { const ok = await runGroup(group) if (!ok) { setSequencing(false); return } } setSequencing(false) const updated = await api.get(`/manufacturing/devices/${device.serial_number}`) onDeviceUpdated(updated) toast.success('Configured', 'All device settings were applied successfully.') onNext() }, [runGroup, device.serial_number, onDeviceUpdated, onNext, toast]) const handlePush = () => { const groups = buildGroups() groupsRef.current = groups setMode('push') setGroupStatus({}) runSequence(groups) } const handleRetryFailed = () => { if (!failedGroup) return const remaining = groupsRef.current.slice(groupsRef.current.findIndex((g) => g.key === failedGroup.group.key)) setFailedGroup(null) runSequence(remaining) } const handleForceProceed = () => { toast.danger('Skipped', `${failedGroup.group.label} was not confirmed by the device — settings were not saved.`) setFailedGroup(null) setSequencing(false) onNext() } const handleSaveOnly = async () => { setMode('save-only') try { const groups = buildGroups() const merged = groups.reduce( (acc, g) => deepMerge(acc, g.firestorePatch), { device_attributes: {} } ) await api.put(`/devices/${device.id}`, merged) const updated = await api.get(`/manufacturing/devices/${device.serial_number}`) onDeviceUpdated(updated) toast.success('Saved', 'Settings saved to the console. They will need to be pushed to the device once it comes online.') onNext() } catch (err) { toast.danger('Error', err.message || 'Failed to save settings.') } } const handleSkipConfigure = () => { onNext() } // ── Loading gate — waits on the device's saved attributes + online status ── if (loading) { return
} if (!mode) { return (
{isOnline ? 'Device Online' : 'Device Offline'} {isOnline ? 'Commands can be sent and confirmed live.' : 'The device is not currently reachable over MQTT.'}
{isOnline ? (

Configure logging, bells, clock, alerts, and backlight below, then push all settings to the device.

) : (

This device is offline, so settings can't be pushed and confirmed right now. You can save your configuration to the console only (it will need to be pushed once the device comes online), or skip configuration entirely for now.

)}
{isOnline ? ( ) : ( )}
) } // ── Sequencing / result view ───────────────────────────────────────── return (

Sending configuration to the device. Each group is applied and confirmed before moving to the next.

({ key: t.key, label: `${t.label} Settings` }))} status={groupStatus} /> {failedGroup && (

"{failedGroup.group.label}" failed after 3 attempts: {failedGroup.error?.message || 'The device did not confirm the change.'}

)} {!sequencing && !failedGroup && (
)}
) } // ─── Finalize step — Location tab (map picker) ──────────────────────────────── // Custom themed pin — avoids Leaflet's default marker PNG asset-path issue // under Vite, and matches the app's design tokens instead of stock blue. const pinIcon = L.divIcon({ className: 'onboard-map-pin', html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }) function MapClickHandler({ onPick }) { useMapEvents({ click: (e) => onPick(e.latlng.lat, e.latlng.lng) }) return null } // Leaflet measures its container's size once at mount — if that happens // while the tab is still transitioning in (or was previously hidden), tiles // beyond the stale measured size never load. Force a re-measure just after // mount so the full container is always tiled. function MapSizeFixup() { const map = useMap() useEffect(() => { const t = setTimeout(() => map.invalidateSize(), 50) return () => clearTimeout(t) }, [map]) return null } function LocationTab({ values, onChange }) { const { locationName, lat, lng } = values const hasCoords = lat !== '' && lng !== '' && !isNaN(parseFloat(lat)) && !isNaN(parseFloat(lng)) const center = hasCoords ? [parseFloat(lat), parseFloat(lng)] : [37.9838, 23.7275] // Athens fallback const handlePick = (newLat, newLng) => { onChange({ ...values, lat: newLat.toFixed(7), lng: newLng.toFixed(7) }) } return (

Set a friendly location name and click the map to place a pin, or enter coordinates manually.

onChange({ ...values, locationName: e.target.value })} placeholder="e.g. Saint Mary's Church" hint={`${locationName.length}/35 characters`} inputProps={{ maxLength: 35 }} />
{hasCoords && ( { const pos = e.target.getLatLng() handlePick(pos.lat, pos.lng) }, }} /> )}
onChange({ ...values, lat: e.target.value })} placeholder="e.g. 37.9838" inputProps={{ step: '0.0000001' }} /> onChange({ ...values, lng: e.target.value })} placeholder="e.g. 23.7275" inputProps={{ step: '0.0000001' }} />
{hasCoords && ( Preview on Google Maps ↗ )}
) } // ─── Finalize step — Notes & Issues tab ─────────────────────────────────────── const ISSUE_STATUS_META = { open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' }, researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' }, resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' }, } function IssueStatusPill({ status }) { const m = ISSUE_STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' } return ( {m.label} ) } function NotesTab({ device }) { const { toast } = useToast() const [issues, setIssues] = useState([]) const [loading, setLoading] = useState(false) const [issueModal, setIssueModal] = useState({ open: false, entry: null }) const displayName = device.console_name || device.device_name || device.serial_number const loadIssues = useCallback(async () => { setLoading(true) try { const data = await api.get(`/notes/by-entity/device/${device.id}`) setIssues(Array.isArray(data) ? data : []) } catch { setIssues([]) } finally { setLoading(false) } }, [device.id]) useEffect(() => { loadIssues() }, [loadIssues]) const handleSaved = () => { setIssueModal({ open: false, entry: null }) loadIssues() } const handleDeleted = async (entryId) => { setIssueModal({ open: false, entry: null }) try { await api.delete(`/notes/${entryId}`) toast.success('Deleted', 'Note removed.') loadIssues() } catch (err) { toast.danger('Error', err.message || 'Failed to delete note.') } } return (

Log notes or issues for this device — visible on its Manage tab afterward.

{loading ? (
) : issues.length === 0 ? (

No notes or issues logged yet.

) : (
{issues.map((issue) => (
setIssueModal({ open: true, entry: issue })} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', border: '1px solid var(--color-border)', backgroundColor: 'var(--color-bg-elevated)', cursor: 'pointer', }} > {issue.status && }

{issue.title}

{issue.body && (

{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}

)}
{fmtRelative(issue.created_at)}
))}
)} setIssueModal({ open: false, entry: null })} onSaved={handleSaved} onDelete={handleDeleted} />
) } // ─── Finalize step ──────────────────────────────────────────────────────────── const FINALIZE_TABS = [ { key: 'details', label: 'Details' }, { key: 'location', label: 'Location' }, { key: 'notes', label: 'Notes & Issues' }, ] function FinalizeStep({ device, onDeviceUpdated, onFinish, onBack, finishing }) { const { toast } = useToast() const [subTab, setSubTab] = useState('details') const [loading, setLoading] = useState(true) const [deviceName, setDeviceName] = useState('') const [friendlyNameVal, setFriendlyNameVal] = useState('') const [tags, setTagsState] = useState([]) const [tagInput, setTagInput] = useState('') const [notes, setNotes] = useState('') const [location, setLocation] = useState({ locationName: '', lat: '', lng: '' }) const [saving, setSaving] = useState(false) // Onboarding can also be used to reconfigure an already-deployed device, so // always pull the current name/tags/notes/location from the device doc — // DeviceInventoryItem (the wizard's cached shape) doesn't carry tags, // staffNotes, or location fields at all. const loadDetails = useCallback(async () => { setLoading(true) try { const d = await api.get(`/devices/${device.id}`) setDeviceName(d.device_name || '') setFriendlyNameVal(d.console_name || '') setTagsState(d.tags || []) setNotes(d.staffNotes || '') const c = d.device_location_coordinates const latVal = c?.lat ?? c?.latitude const lngVal = c?.lng ?? c?.longitude setLocation({ locationName: d.device_location || '', lat: latVal != null ? String(latVal) : '', lng: lngVal != null ? String(lngVal) : '', }) } finally { setLoading(false) } }, [device.id]) useEffect(() => { loadDetails() }, [loadDetails]) const handleAddTag = () => { const trimmed = tagInput.trim() if (!trimmed || tags.includes(trimmed)) return setTagsState((prev) => [...prev, trimmed]) setTagInput('') } const handleRemoveTag = (tag) => setTagsState((prev) => prev.filter((t) => t !== tag)) const handleFinish = async () => { setSaving(true) try { const latNum = parseFloat(location.lat) const lngNum = parseFloat(location.lng) const coordPayload = (!isNaN(latNum) && !isNaN(lngNum)) ? { lat: latNum, lng: lngNum } : null await api.put(`/devices/${device.id}`, { device_name: deviceName, console_name: friendlyNameVal, staffNotes: notes, device_location: location.locationName, device_location_coordinates: coordPayload, }) await api.put(`/devices/${device.id}/tags`, { tags }) // Merge only the fields this step actually changed — PUT /devices/{id} // returns the DeviceInDB shape, which doesn't line up field-for-field // with the DeviceInventoryItem shape the rest of the wizard uses. onDeviceUpdated((prev) => ({ ...prev, device_name: deviceName, console_name: friendlyNameVal, staffNotes: notes, tags, device_location: location.locationName, device_location_coordinates: coordPayload, })) await onFinish() } catch (err) { toast.danger('Error', err.message || 'Failed to save final details.') } finally { setSaving(false) } } if (loading) { return
} return (

Set the device's names, location, tags, and notes before completing onboarding.

setDeviceName(e.target.value)} placeholder="Name shown in the mobile app" /> setFriendlyNameVal(e.target.value)} placeholder="Console-only name" />

Tags

{tags.map((tag) => ( {tag} ))}
setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }} placeholder="Add tag…" className="input" style={{ flex: '0 1 240px' }} />
setNotes(e.target.value)} placeholder="Internal notes about this device or installation…" />
{subTab === 'location' && (
)}
) } // ─── Device workspace (wizard steps) ────────────────────────────────────────── function DeviceWorkspace({ device, onDeviceUpdated, onDone, onChangeDevice }) { const { toast } = useToast() const [stepIndex, setStepIndex] = useState(1) // 0 = device (already selected), 1 = assign const [finishing, setFinishing] = useState(false) const planRef = useRef(null) const goNext = () => setStepIndex((i) => Math.min(i + 1, STEPS.length - 1)) const goBack = () => setStepIndex((i) => Math.max(i - 1, 1)) const handleSavePlanAndContinue = async () => { setFinishing(true) try { await planRef.current?.save() goNext() } catch (err) { toast.danger('Error', err.message || 'Failed to save the plan.') } finally { setFinishing(false) } } const handleFinish = async () => { onDone() } return ( Change Device} >
{stepIndex === 1 && ( <>

Customer

App Users

)} {stepIndex === 2 && ( <>
)} {stepIndex === 3 && ( )} {stepIndex === 4 && ( )}
) } // ─── OnboardDevice ──────────────────────────────────────────────────────────── export default function OnboardDevice() { const { hasPermission } = useAuth() const { toast } = useToast() const canEdit = hasPermission('devices', 'edit') const [searchParams, setSearchParams] = useSearchParams() const [device, setDevice] = useState(null) const [loading, setLoading] = useState(false) const preloadSn = searchParams.get('sn') || '' useEffect(() => { if (!preloadSn) return setLoading(true) api.get(`/manufacturing/devices/${preloadSn}`) .then(setDevice) .catch(() => {}) .finally(() => setLoading(false)) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const handleSelect = (d) => { setDevice(d) setSearchParams({ sn: d.serial_number }) } const handleChangeDevice = () => { setDevice(null) setSearchParams({}) } const handleDone = () => { toast.success('Onboarded', `${device.serial_number} is ready.`) handleChangeDevice() } return (
{!canEdit ? (

You don't have permission to onboard devices.

) : loading ? (
) : device ? ( ) : ( )}
) }