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>
2113 lines
97 KiB
React
2113 lines
97 KiB
React
// 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="© 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>
|
||
)
|
||
}
|