Files
bellsystems-cp/frontend/src/pages/bellcloud/devices/OnboardDevice.jsx
T
bonaminandClaude Sonnet 5 80c0e955f0 fix(devices): zero out unassigned bell outputs when saving Bell Mechanisms
relay.set_config is a partial update on the firmware side — any bell
index not present in the durations/outputs maps is left completely
untouched. EditBellOutputsModal and OnboardDevice's bell config step
both only sent entries for the currently-active bell count, so
deselecting a bell (lowering the active count) never actually
deactivated its output on the device — it stayed wired to whatever it
was last set to. The only way to disable a bell was to reselect it and
explicitly set it to "Disabled", which isn't obvious.

Both now send all maxOutputs slots on every save, forcing output to 0
for anything beyond the active count, so lowering the active bell
count reaches the firmware the way it visually appears to in the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:35:03 +03:00

2113 lines
97 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
{steps.map((step, i) => {
const done = i < activeIndex
const active = i === activeIndex
return (
<div key={step.key} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flex: i < steps.length - 1 ? 1 : undefined }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
<div style={{
width: 26, height: 26, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)',
flexShrink: 0,
backgroundColor: done ? 'var(--color-success)' : active ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
color: done || active ? 'var(--color-bg-base)' : 'var(--color-text-muted)',
border: done || active ? 'none' : '1px solid var(--color-border-strong)',
}}>
{done ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6L9 17l-5-5" /></svg>
) : i + 1}
</div>
<span style={{
fontSize: 'var(--font-size-sm)',
fontWeight: active ? 'var(--font-weight-semibold)' : 'var(--font-weight-medium)',
color: active ? 'var(--color-text-primary)' : done ? 'var(--color-text-secondary)' : 'var(--color-text-muted)',
whiteSpace: 'nowrap',
}}>
{step.label}
</span>
</div>
{i < steps.length - 1 && (
<div style={{ flex: 1, height: 1, backgroundColor: done ? 'var(--color-success)' : 'var(--color-border)', minWidth: 24 }} />
)}
</div>
)
})}
</div>
)
}
// ─── 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 <span style={{ color: 'var(--color-text-muted)' }}>0</span>
}
return (
<span
ref={anchorRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)', cursor: 'default', color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}
>
{count}
{open && createPortal(
<div
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
className="select-menu"
style={{ top: pos.top, left: pos.left, minWidth: 220, padding: 'var(--space-2)' }}
>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.03em', padding: 'var(--space-1) var(--space-2)' }}>
Assigned Users
</p>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{users.map((u) => (
<div key={u.user_id} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', padding: 'var(--space-1) var(--space-2)' }}>
<div style={{ width: 20, height: 20, borderRadius: '50%', backgroundColor: 'var(--color-success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '10px', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-success)', flexShrink: 0 }}>
{(u.display_name || u.email || '?')[0].toUpperCase()}
</div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{u.display_name || u.email || u.user_id}
</span>
</div>
))}
</div>
</div>,
document.body
)}
</span>
)
}
// ─── Device picker ────────────────────────────────────────────────────────────
const TABLE_COLUMNS = [
{
key: 'serial_number', label: 'Serial Number', sortable: true,
render: (d) => <span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>{d.serial_number}</span>,
},
{
key: 'friendly_name', label: 'Friendly Name',
render: (d) => friendlyName(d) || <span style={{ color: 'var(--color-text-muted)' }}>—</span>,
},
{
key: 'hw_type', label: 'Board Type', sortable: true,
render: (d) => d.hw_type || <span style={{ color: 'var(--color-text-muted)' }}>—</span>,
},
{
key: 'mfg_status', label: 'Status',
render: (d) => <StatusBadge variant={STATUS_VARIANT[d.mfg_status] || 'neutral'}>{d.mfg_status}</StatusBadge>,
},
{
key: 'customer_id', label: 'Customer',
render: (d) => d.customer_id
? <StatusBadge variant="success" dot={false} size="sm">{d.customer_name || 'Assigned'}</StatusBadge>
: <StatusBadge variant="neutral" dot={false} size="sm">Unassigned</StatusBadge>,
},
{
key: 'user_list', label: 'Users',
render: (d) => <UsersHoverCell users={d.users} />,
},
]
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 (
<Card title="Select a Device" subtitle="Only devices that have been flashed and verified online are onboardable.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', alignItems: 'center' }}>
<div style={{ flex: '1 1 240px', minWidth: 200 }}>
<SearchBar value={search} onChange={setSearch} placeholder="Search serial, name, board type, customer, users…" />
</div>
<SegmentedControl options={USERS_FILTER_OPTIONS} value={usersFilter} onChange={setUsersFilter} size="md" />
<div style={{ width: 220, flexShrink: 0 }}>
<MultiSelect
value={boardTypes}
onChange={setBoardTypes}
placeholder="All Board Types"
options={BOARD_TYPE_OPTIONS}
/>
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={() => { setUsersFilter('all'); setBoardTypes([]) }}>
Clear filters
</Button>
)}
</div>
{error && (
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
{error}
</div>
)}
<DataTable
columns={TABLE_COLUMNS}
data={filtered}
loading={loading}
keyField="id"
emptyMessage="No onboardable devices found"
emptyDescription="Devices must be flashed and verified online before they can be onboarded."
onRowClick={(d) => onSelect(d)}
skeletonRows={6}
/>
</div>
</Card>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Assign this device to the customer that ordered it.
</p>
<div style={{ display: 'flex', gap: 'var(--space-2)', flexShrink: 0 }}>
<Button variant="ghost" size="sm" onClick={() => setShowCreate(true)}>
Create Customer
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAssign(true)}>
Assign Customer
</Button>
</div>
</div>
{loadingCustomer ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner size="sm" /></div>
) : assignedCustomer ? (
<div 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)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: 'var(--color-primary-subtle)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-primary)', flexShrink: 0 }}>
{(assignedCustomer.name || '?')[0].toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{[assignedCustomer.name, assignedCustomer.surname].filter(Boolean).join(' ') || assignedCustomer.email}
</p>
{assignedCustomer.organization && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{assignedCustomer.organization}</p>}
</div>
<StatusBadge variant="success" dot={false} size="sm">Assigned</StatusBadge>
</div>
) : (
<p style={{ padding: 'var(--space-6)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center' }}>
No customer assigned yet.
</p>
)}
<AssignCustomerModal
open={showAssign}
onClose={() => setShowAssign(false)}
onSelect={handleAssignSelected}
deviceId={device.id}
/>
<CreateCustomerModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={handleCustomerCreated}
/>
{assigning && (
<div style={{ display: 'flex', justifyContent: 'center' }}><Spinner size="sm" /></div>
)}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Grant app-user accounts access to view and control this device.
</p>
<div style={{ display: 'flex', gap: 'var(--space-2)', flexShrink: 0 }}>
<Button variant="ghost" size="sm" onClick={() => setShowCreateUser(true)}>
Create User
</Button>
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>
+ Add User
</Button>
</div>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner size="sm" /></div>
) : users.length === 0 ? (
<p style={{ padding: 'var(--space-6)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center' }}>
No users assigned yet.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{users.map((u) => (
<div key={u.user_id} 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)' }}>
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: 'var(--color-success-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-success)', flexShrink: 0 }}>
{(u.display_name || u.email || '?')[0].toUpperCase()}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>{u.display_name || u.email}</p>
{u.email && u.display_name && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{u.email}</p>}
</div>
<button
onClick={() => handleRemoveUser(u)}
disabled={removingUser === u.user_id}
title="Remove access" aria-label="Remove user"
style={{
width: 26, height: 26, borderRadius: 'var(--radius-sm)',
border: '1px solid transparent', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
</div>
))}
</div>
)}
<AddDeviceUserModal
open={showAddUser}
deviceId={device.id}
existingUserIds={existingUserIds}
onClose={() => setShowAddUser(false)}
onAdded={handleUserAdded}
/>
<CreateUserModal
open={showCreateUser}
onClose={() => setShowCreateUser(false)}
onCreated={handleUserCreated}
/>
</div>
)
}
// ─── 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 <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner size="sm" /></div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
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.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="Tier" name="tier" type="select" value={tier} onChange={(e) => setTier(e.target.value)}>
{TIERS.map((t) => <option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>)}
</FormField>
<FormField
label="Start Date"
name="startDate"
type="date"
value={startDateStr}
onChange={(e) => setStartDateStr(e.target.value)}
/>
<FormField
label="Subscription Duration (days)"
name="duration"
type="number"
value={String(duration)}
onChange={(e) => setDuration(Math.max(0, parseInt(e.target.value) || 0))}
inputProps={{ min: 0 }}
/>
<FormField label="Max Users" name="maxUsers" type="select" value={maxUsers} onChange={(e) => setMaxUsers(e.target.value)}>
{Array.from({ length: 10 }, (_, i) => i + 1).map((n) => <option key={n} value={String(n)}>{n} {n === 1 ? 'User' : 'Users'}</option>)}
</FormField>
<FormField label="Max Outputs" name="maxOutputs" type="select" value={maxOutputs} onChange={(e) => setMaxOutputs(e.target.value)}>
{Array.from({ length: 16 }, (_, i) => i + 1).map((n) => <option key={n} value={String(n)}>{n} {n === 1 ? 'Output' : 'Outputs'}</option>)}
</FormField>
<FormField
label="Warranty Period (days)"
name="warrantyPeriod"
type="number"
value={String(warrantyPeriod)}
onChange={(e) => setWarrantyPeriod(Math.max(0, parseInt(e.target.value) || 0))}
inputProps={{ min: 0 }}
/>
</div>
</div>
)
})
// ─── Configure step — Log tab ─────────────────────────────────────────────────
function LogConfigTab({ values, onChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Set the logging verbosity for each output. 0 = Disabled, 5 = Verbose.
</p>
{['serial', 'sd', 'mqtt'].map((field) => {
const level = values[field]
const meta = LOG_LEVEL_META[level]
return (
<div key={field} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', textTransform: 'capitalize' }}>
{field} Log
</span>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', fontFamily: 'var(--font-family-mono)', color: meta?.color || 'var(--color-text-muted)' }}>
{meta?.label || '—'}
</span>
</div>
<input
type="range" min={0} max={5} step={1} value={level}
onChange={(e) => onChange({ ...values, [field]: Number(e.target.value) })}
style={{ width: '100%', accentColor: meta?.color || 'var(--color-primary)', cursor: 'pointer' }}
/>
</div>
)
})}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>Bell Master Control</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 2 }}>Enable or disable the bell mechanism entirely</p>
</div>
<button
type="button" role="switch" aria-checked={masterOn}
onClick={() => onChange({ ...values, masterOn: !masterOn })}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 'var(--radius-full)',
backgroundColor: masterOn ? 'var(--color-success)' : 'var(--color-bg-island)',
border: `1px solid ${masterOn ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
cursor: 'pointer', flexShrink: 0, padding: 0, transition: 'background-color 0.2s, border-color 0.2s',
}}
>
<div style={{
position: 'absolute', top: 3, left: masterOn ? 21 : 3, width: 16, height: 16,
borderRadius: 'var(--radius-full)', backgroundColor: masterOn ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
transition: 'left 0.2s',
}} />
</button>
</div>
<div style={{ height: 1, backgroundColor: 'var(--color-border)' }} />
<div>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-2)' }}>
Active Bells: <strong style={{ color: 'var(--color-text-primary)' }}>{activeBells}</strong>
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{Array.from({ length: maxOutputs }, (_, i) => {
const n = i + 1
const isActive = n <= activeBells
return (
<button
key={n} type="button"
onClick={() => onChange({ ...values, activeBells: n === activeBells ? 0 : n })}
style={{
width: 40, height: 40, borderRadius: 'var(--radius-md)',
border: `1px solid ${isActive ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
backgroundColor: isActive ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
color: isActive ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontWeight: isActive ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
fontSize: 'var(--font-size-sm)', cursor: 'pointer', transition: 'all 0.15s',
}}
>
{n}
</button>
)
})}
</div>
</div>
{activeBells > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '56px 1fr 1fr', gap: 'var(--space-2)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', paddingBottom: 'var(--space-1)', borderBottom: '1px solid var(--color-border)' }}>
<span>Bell</span><span>Output</span><span>Timing</span>
</div>
{activeRows.map((row, i) => (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '56px 1fr 1fr', gap: 'var(--space-2)', alignItems: 'end' }}>
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', paddingBottom: 'var(--space-2)' }}>
Bell {i + 1}
</span>
<FormField label="" name={`output-${i}`} type="select" value={String(row.output)} onChange={(e) => updateRow(i, 'output', e.target.value)}>
<option value="0">Disabled</option>
{Array.from({ length: maxOutputs }, (_, j) => {
const out = j + 1
const reserved = reservedSet.has(out) && out !== row.output
return <option key={out} value={String(out)} disabled={reserved}>Output {out}{reserved ? ' — In Use' : ''}</option>
})}
</FormField>
<FormField label="" name={`timing-${i}`} type="select" value={String(row.timing)} onChange={(e) => updateRow(i, 'timing', e.target.value)}>
{TIMING_OPTIONS.map((ms) => <option key={ms} value={String(ms)}>{TIMING_LABELS[ms]}</option>)}
</FormField>
</div>
))}
</div>
)}
</div>
)
}
// ─── Configure step — Clock tab ───────────────────────────────────────────────
function StepSlider({ steps, value, onChange, label }) {
const idx = steps.indexOf(value)
const currentIdx = idx >= 0 ? idx : 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)' }}>{label}</span>
<span style={{ fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)', letterSpacing: 'var(--tracking-tight)', color: 'var(--color-primary)', lineHeight: 1 }}>
{msLabel(steps[currentIdx])}
</span>
</div>
<input
type="range" min={0} max={steps.length - 1} step={1} value={currentIdx}
onChange={(e) => onChange(steps[Number(e.target.value)])}
style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{msLabel(steps[0])}</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{msLabel(steps[steps.length - 1])}</span>
</div>
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>Clock Master Control</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 2 }}>Enable or disable the clock mechanism entirely</p>
</div>
<button
type="button" role="switch" aria-checked={masterOn}
onClick={() => onChange({ ...values, masterOn: !masterOn })}
style={{
position: 'relative', width: 44, height: 24, borderRadius: 'var(--radius-full)',
backgroundColor: masterOn ? 'var(--color-success)' : 'var(--color-bg-island)',
border: `1px solid ${masterOn ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
cursor: 'pointer', flexShrink: 0, padding: 0, transition: 'background-color 0.2s, border-color 0.2s',
}}
>
<div style={{ position: 'absolute', top: 3, left: masterOn ? 21 : 3, width: 16, height: 16, borderRadius: 'var(--radius-full)', backgroundColor: masterOn ? 'var(--color-text-inverse)' : 'var(--color-text-muted)', transition: 'left 0.2s' }} />
</button>
</div>
<div style={{ height: 1, backgroundColor: 'var(--color-border)' }} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="ODD Output" name="clockOut1" type="select" value={String(out1)} onChange={(e) => onChange({ ...values, out1: Number(e.target.value) })}>
<option value="0">Disconnected</option>
{outputOptions.map(({ out, isBell, reserved }) => {
const disabled = out !== out1 && (reserved || out === out2)
return <option key={out} value={String(out)} disabled={disabled}>Output {out}{isBell ? ' — In Use (Bell)' : reserved ? ' — In Use' : out === out2 ? ' — Selected as EVEN' : ''}</option>
})}
</FormField>
<FormField label="EVEN Output" name="clockOut2" type="select" value={String(out2)} onChange={(e) => onChange({ ...values, out2: Number(e.target.value) })}>
<option value="0">Disconnected</option>
{outputOptions.map(({ out, isBell, reserved }) => {
const disabled = out !== out2 && (reserved || out === out1)
return <option key={out} value={String(out)} disabled={disabled}>Output {out}{isBell ? ' — In Use (Bell)' : reserved ? ' — In Use' : out === out1 ? ' — Selected as ODD' : ''}</option>
})}
</FormField>
</div>
<div style={{ height: 1, backgroundColor: 'var(--color-border)' }} />
<StepSlider label="Pulse Duration" steps={PULSE_STEPS} value={pulse} onChange={(v) => onChange({ ...values, pulse: v })} />
<StepSlider label="Pause Duration" steps={PAUSE_STEPS} value={pause} onChange={(v) => onChange({ ...values, pause: v })} />
{(conflictMsg || validationMsg) && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-bg)', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-sm)' }}>
{conflictMsg || validationMsg}
</p>
)}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', gap: 0, borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
{options.map((opt, i) => (
<button
key={opt.value} type="button" onClick={() => onChange(opt.value)}
style={{
flex: 1, padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-sm)',
fontWeight: value === opt.value ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
color: value === opt.value ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
backgroundColor: value === opt.value ? 'var(--color-primary-container)' : 'var(--color-bg-elevated)',
border: 'none', borderLeft: i > 0 ? '1px solid var(--color-border)' : 'none',
cursor: 'pointer', transition: 'background-color 0.15s, color 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
)
}
function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChange }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>{title}</span>
<button
type="button" role="switch" aria-checked={isOn} onClick={onToggle}
style={{
position: 'relative', width: 36, height: 20, borderRadius: 'var(--radius-full)',
backgroundColor: isOn ? 'var(--color-primary-container)' : 'var(--color-bg-island)',
border: `1px solid ${isOn ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
cursor: 'pointer', padding: 0, transition: 'background-color 0.2s, border-color 0.2s', flexShrink: 0,
}}
>
<div style={{ position: 'absolute', top: 2, left: isOn ? 17 : 2, width: 14, height: 14, borderRadius: 'var(--radius-full)', backgroundColor: isOn ? 'var(--color-primary)' : 'var(--color-text-muted)', transition: 'left 0.2s' }} />
</button>
</div>
{isOn && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="From" name={`${title}-from`} value={from} onChange={(e) => onFromChange(e.target.value)} placeholder="HH:MM" />
<FormField label="Until" name={`${title}-to`} value={to} onChange={(e) => onToChange(e.target.value)} placeholder="HH:MM" />
</div>
)}
</div>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)' }}>Alert Type</span>
<AlertTypeSelector value={alertType} onChange={(v) => onChange({ ...values, alertType: v })} />
</div>
{alertType === 'multi' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)' }}>Alert Tempo</span>
<span style={{ fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-lg)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-primary)', letterSpacing: 'var(--tracking-tight)' }}>{msLabel(tempo || 1000)}</span>
</div>
<input
type="range" min={TEMPO_MIN} max={TEMPO_MAX} step={TEMPO_STEP}
value={tempo || 1000}
onChange={(e) => onChange({ ...values, tempo: Number(e.target.value) })}
style={{ width: '100%', accentColor: 'var(--color-primary)', cursor: 'pointer' }}
/>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{msLabel(TEMPO_MIN)}</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{msLabel(TEMPO_MAX)}</span>
</div>
</div>
)}
<div style={{ height: 1, backgroundColor: 'var(--color-border)' }} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="Hours Bell" name="hourBell" type="select" value={hourBell} onChange={(e) => onChange({ ...values, hourBell: e.target.value })}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</FormField>
<FormField label="Half-Hours Bell" name="halfBell" type="select" value={halfBell} onChange={(e) => onChange({ ...values, halfBell: e.target.value })}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</FormField>
<FormField label="Quarters Bell" name="quarterBell" type="select" value={quarterBell} onChange={(e) => onChange({ ...values, quarterBell: e.target.value })}>
{bellOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</FormField>
</div>
<div style={{ height: 1, backgroundColor: 'var(--color-border)' }} />
<SilenceBlock title="Day Silence" isOn={isDayOn} onToggle={() => onChange({ ...values, isDayOn: !isDayOn })} from={dayFrom} onFromChange={(v) => onChange({ ...values, dayFrom: v })} to={dayTo} onToChange={(v) => onChange({ ...values, dayTo: v })} />
<SilenceBlock title="Night Silence" isOn={isNightOn} onToggle={() => onChange({ ...values, isNightOn: !isNightOn })} from={nightFrom} onFromChange={(v) => onChange({ ...values, nightFrom: v })} to={nightTo} onToChange={(v) => onChange({ ...values, nightTo: v })} />
{validationMsg && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-bg)', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-sm)' }}>
{validationMsg}
</p>
)}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<FormField label="Automation" name="backlightEnabled" type="select" value={String(enabled)} onChange={(e) => onChange({ ...values, enabled: e.target.value === 'true' })}>
<option value="true">Automated</option>
<option value="false">Disabled</option>
</FormField>
<FormField label="Output" name="backlightOutput" type="select" value={String(output)} onChange={(e) => onChange({ ...values, output: Number(e.target.value) })}>
<option value="0">Disabled</option>
{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 <option key={out} value={String(out)} disabled={reserved}>Output {out}{isBell ? ' — In Use (Bell)' : isClock ? ' — In Use (Clock)' : ''}</option>
})}
</FormField>
{enabled && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="Turn On Time" name="backlightOn" value={onTime} onChange={(e) => onChange({ ...values, onTime: e.target.value })} placeholder="HH:MM" />
<FormField label="Turn Off Time" name="backlightOff" value={offTime} onChange={(e) => onChange({ ...values, offTime: e.target.value })} placeholder="HH:MM" />
</div>
)}
{validationMsg && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-bg)', padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-sm)' }}>
{validationMsg}
</p>
)}
</div>
)
}
// ─── 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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{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 (
<div key={g.key} style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-3)', padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)', backgroundColor: cardStyle.bg,
border: `1px solid ${cardStyle.border}`, transition: 'background-color 0.2s, border-color 0.2s',
}}>
<div style={{ width: 20, height: 20, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{st === 'running' ? <Spinner size="sm" /> : icon ? <Icon name={icon.name} size={16} color={icon.color} /> : (
<div style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: 'var(--color-border-strong)' }} />
)}
</div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', flex: 1 }}>{g.label}</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'capitalize' }}>{st}</span>
</div>
)
})}
</div>
)
}
// ─── 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 <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-10)' }}><Spinner size="md" /></div>
}
if (!mode) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<StatusBadge variant={isOnline ? 'success' : 'neutral'}>{isOnline ? 'Device Online' : 'Device Offline'}</StatusBadge>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
{isOnline ? 'Commands can be sent and confirmed live.' : 'The device is not currently reachable over MQTT.'}
</span>
</div>
{isOnline ? (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Configure logging, bells, clock, alerts, and backlight below, then push all settings to the device.
</p>
) : (
<div style={{ padding: 'var(--space-4)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)' }}>
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.
</p>
</div>
)}
<Tabs tabs={CONFIGURE_TABS} active={subTab} onChange={setSubTab} />
<div style={{ display: subTab === 'log' ? 'block' : 'none' }}><LogConfigTab values={log} onChange={setLog} /></div>
<div style={{ display: subTab === 'bell' ? 'block' : 'none' }}>
<BellConfigTab values={bell} onChange={setBell} maxOutputs={maxOutputs} reservedOutputs={[...clockOutputsUsed, ...backlightOutputUsed]} />
</div>
<div style={{ display: subTab === 'clock' ? 'block' : 'none' }}>
<ClockConfigTab values={clockForm} onChange={setClockForm} maxOutputs={maxOutputs} reservedOutputs={[...bellOutputsUsed, ...backlightOutputUsed]} bellOutputsUsed={bellOutputsUsed} />
</div>
<div style={{ display: subTab === 'alert' ? 'block' : 'none' }}><AlertConfigTab values={alert} onChange={setAlert} connectedBellIndexes={connectedBellIndexes} /></div>
<div style={{ display: subTab === 'backlight' ? 'block' : 'none' }}>
<BacklightConfigTab values={backlight} onChange={setBacklight} maxOutputs={maxOutputs} reservedOutputs={[...bellOutputsUsed, ...clockOutputsUsed]} bellOutputsUsed={bellOutputsUsed} clockOutputsUsed={clockOutputsUsed} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--color-border)' }}>
<Button variant="ghost" onClick={onBack}>Previous Step</Button>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<Button variant="ghost" onClick={handleSkipConfigure}>Skip For Now</Button>
{isOnline ? (
<Button variant="primary" onClick={handlePush}>Push Settings to Device</Button>
) : (
<Button variant="primary" onClick={handleSaveOnly}>Save to Console Only</Button>
)}
</div>
</div>
</div>
)
}
// ── Sequencing / result view ─────────────────────────────────────────
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Sending configuration to the device. Each group is applied and confirmed before moving to the next.
</p>
<SequenceProgress groups={CONFIGURE_TABS.map((t) => ({ key: t.key, label: `${t.label} Settings` }))} status={groupStatus} />
{failedGroup && (
<div style={{ padding: 'var(--space-4)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>
"{failedGroup.group.label}" failed after 3 attempts: {failedGroup.error?.message || 'The device did not confirm the change.'}
</p>
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<Button variant="secondary" size="sm" onClick={handleRetryFailed}>Retry</Button>
<Button variant="danger" size="sm" onClick={handleForceProceed}>Force Proceed Without This Group</Button>
</div>
</div>
)}
{!sequencing && !failedGroup && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="ghost" onClick={() => setMode(null)}>Back to Settings</Button>
</div>
)}
</div>
)
}
// ─── 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: `<div style="
width: 18px; height: 18px; border-radius: 50%;
background: var(--color-primary); border: 2px solid var(--color-text-inverse);
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
"></div>`,
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Set a friendly location name and click the map to place a pin, or enter coordinates manually.
</p>
<FormField
label="Location Name"
name="locationName"
value={locationName}
onChange={(e) => onChange({ ...values, locationName: e.target.value })}
placeholder="e.g. Saint Mary's Church"
hint={`${locationName.length}/35 characters`}
inputProps={{ maxLength: 35 }}
/>
<div style={{ height: 320, borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
<MapContainer center={center} zoom={hasCoords ? 15 : 6} style={{ height: '100%', width: '100%' }}>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="&copy; OpenStreetMap contributors"
/>
<MapClickHandler onPick={handlePick} />
<MapSizeFixup />
{hasCoords && (
<Marker
position={center}
icon={pinIcon}
draggable
eventHandlers={{
dragend: (e) => {
const pos = e.target.getLatLng()
handlePick(pos.lat, pos.lng)
},
}}
/>
)}
</MapContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField
label="Latitude" name="lat" type="number" value={lat}
onChange={(e) => onChange({ ...values, lat: e.target.value })}
placeholder="e.g. 37.9838" inputProps={{ step: '0.0000001' }}
/>
<FormField
label="Longitude" name="lng" type="number" value={lng}
onChange={(e) => onChange({ ...values, lng: e.target.value })}
placeholder="e.g. 23.7275" inputProps={{ step: '0.0000001' }}
/>
</div>
{hasCoords && (
<a
href={`https://www.google.com/maps?q=${lat},${lng}`}
target="_blank" rel="noopener noreferrer"
style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-accent)' }}
>
Preview on Google Maps ↗
</a>
)}
</div>
)
}
// ─── 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 (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '2px 8px',
borderRadius: 'var(--radius-full)', backgroundColor: m.bg, border: `1px solid ${m.color}33`,
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: m.color,
whiteSpace: 'nowrap', flexShrink: 0,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: m.color, flexShrink: 0 }} />
{m.label}
</span>
)
}
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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Log notes or issues for this device — visible on its Manage tab afterward.
</p>
<Button variant="secondary" size="sm" onClick={() => setIssueModal({ open: true, entry: null })}>
+ New Note
</Button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}><Spinner size="sm" /></div>
) : issues.length === 0 ? (
<p style={{ padding: 'var(--space-6)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center' }}>
No notes or issues logged yet.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{issues.map((issue) => (
<div
key={issue.id}
onClick={() => 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 && <IssueStatusPill status={issue.status} />}
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{issue.title}
</p>
{issue.body && (
<p style={{ margin: '2px 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
</p>
)}
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }} title={fmtDateMedium(issue.created_at)}>
{fmtRelative(issue.created_at)}
</span>
</div>
))}
</div>
)}
<EntryFormModal
open={issueModal.open}
entry={issueModal.entry}
defaultType="note"
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: device.id, display_name: displayName, locked: true }]}
knownEntities={{ [device.id]: displayName }}
onClose={() => setIssueModal({ open: false, entry: null })}
onSaved={handleSaved}
onDelete={handleDeleted}
/>
</div>
)
}
// ─── 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 <div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-10)' }}><Spinner size="md" /></div>
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Set the device's names, location, tags, and notes before completing onboarding.
</p>
<Tabs tabs={FINALIZE_TABS} active={subTab} onChange={setSubTab} />
<div style={{ display: subTab === 'details' ? 'flex' : 'none', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField label="Device Name" name="deviceName" value={deviceName} onChange={(e) => setDeviceName(e.target.value)} placeholder="Name shown in the mobile app" />
<FormField label="Friendly Name (Console)" name="friendlyName" value={friendlyNameVal} onChange={(e) => setFriendlyNameVal(e.target.value)} placeholder="Console-only name" />
</div>
<div>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-secondary)', marginBottom: 'var(--space-2)' }}>Tags</p>
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--space-2)' }}>
{tags.map((tag) => (
<span key={tag} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 10px',
borderRadius: 'var(--radius-full)', background: 'var(--color-primary-subtle)',
border: '1px solid rgba(192,193,255,0.20)', fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)',
letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase',
}}>
{tag}
<button type="button" onClick={() => handleRemoveTag(tag)} aria-label={`Remove tag ${tag}`} style={{ background: 'none', border: 'none', padding: 0, lineHeight: 1, cursor: 'pointer', color: 'var(--color-text-muted)', fontSize: 13 }}>×</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--space-2)', alignItems: 'center', marginTop: 'var(--space-2)' }}>
<input
type="text" value={tagInput} onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
placeholder="Add tag…" className="input" style={{ flex: '0 1 240px' }}
/>
<Button variant="secondary" size="md" onClick={handleAddTag} disabled={!tagInput.trim()}>Add</Button>
</div>
</div>
<FormField label="Staff Notes (Quick Note)" name="staffNotes" type="textarea" rows={4} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Internal notes about this device or installation…" />
</div>
{subTab === 'location' && (
<div>
<LocationTab values={location} onChange={setLocation} />
</div>
)}
<div style={{ display: subTab === 'notes' ? 'block' : 'none' }}>
<NotesTab device={device} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--color-border)' }}>
<Button variant="ghost" onClick={onBack}>Previous Step</Button>
<Button variant="primary" onClick={handleFinish} loading={saving || finishing}>
Finish Onboarding
</Button>
</div>
</div>
)
}
// ─── 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 (
<Card
title={device.serial_number}
subtitle={`${friendlyName(device) || device.hw_type || '—'} · ${device.mfg_status}`}
action={<Button variant="ghost" size="sm" onClick={onChangeDevice}>Change Device</Button>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
<StepIndicator steps={STEPS} activeIndex={stepIndex} />
{stepIndex === 1 && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5)', alignItems: 'stretch' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', minWidth: 0 }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
Customer
</p>
<CustomerTab device={device} onDeviceUpdated={onDeviceUpdated} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', minWidth: 0, borderLeft: '1px solid var(--color-border)', paddingLeft: 'var(--space-5)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>
App Users
</p>
<UsersTab device={device} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--color-border)' }}>
<Button variant="ghost" onClick={onChangeDevice}>Previous Step</Button>
<Button variant="primary" onClick={goNext}>
Next Step
</Button>
</div>
</>
)}
{stepIndex === 2 && (
<>
<PlanStep ref={planRef} device={device} onDeviceUpdated={onDeviceUpdated} />
<div style={{ display: 'flex', justifyContent: 'space-between', paddingTop: 'var(--space-3)', borderTop: '1px solid var(--color-border)' }}>
<Button variant="ghost" onClick={goBack}>Back</Button>
<Button variant="primary" onClick={handleSavePlanAndContinue} loading={finishing}>
Next Step
</Button>
</div>
</>
)}
{stepIndex === 3 && (
<ConfigureStep
device={device}
onDeviceUpdated={onDeviceUpdated}
onNext={goNext}
onBack={goBack}
/>
)}
{stepIndex === 4 && (
<FinalizeStep
device={device}
onDeviceUpdated={onDeviceUpdated}
onFinish={handleFinish}
onBack={goBack}
finishing={finishing}
/>
)}
</div>
</Card>
)
}
// ─── 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 (
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': 'calc(var(--content-max-width-lg) * 1.2)' }}>
<PageHeader
title="Onboard Device"
subtitle="Assign a customer and users, then set the plan for a verified device."
/>
{!canEdit ? (
<Card>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', textAlign: 'center', padding: 'var(--space-6)' }}>
You don't have permission to onboard devices.
</p>
</Card>
) : loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-12)' }}>
<Spinner size="lg" />
</div>
) : device ? (
<DeviceWorkspace
device={device}
onDeviceUpdated={setDevice}
onDone={handleDone}
onChangeDevice={handleChangeDevice}
/>
) : (
<DevicePicker onSelect={handleSelect} />
)}
</div>
)
}