feat(devices): add device onboarding wizard

New guided flow (Bell Cloud > Devices > Onboard) for claiming a
manufactured/flashed device into a customer's fleet in one pass:
looks up the device by serial, finds or creates the customer
(CreateCustomerModal for a quick inline create), finds or creates the
app user account (reuses CreateUserModal from the user-creation work),
and assigns the device — replacing what used to require jumping
between the inventory, CRM, and user-management pages separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 18:34:32 +03:00
co-authored by Claude Sonnet 5
parent 6686faad6b
commit 4737ab4306
4 changed files with 2252 additions and 0 deletions
@@ -68,6 +68,7 @@ const Icons = {
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>, commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
blackBox: () => <SvgIcon Component={IcoBlackbox} />, blackBox: () => <SvgIcon Component={IcoBlackbox} />,
deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />, deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />,
onboard: () => <S><path d="M5 8l2 2 4-4"/><rect x="2" y="2" width="12" height="12" rx="2"/></S>,
appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>, appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>,
melodies: () => <SvgIcon Component={IcoMelodies} />, melodies: () => <SvgIcon Component={IcoMelodies} />,
library: () => <SvgIcon Component={IcoMelodiesEditor} />, library: () => <SvgIcon Component={IcoMelodiesEditor} />,
@@ -132,6 +133,7 @@ const navSections = [
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' }, { to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' }, { to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
{ to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true }, { to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true },
{ to: '/devices/onboard', label: 'Onboard', icon: 'onboard' },
], ],
}, },
{ to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' }, { to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' },
@@ -0,0 +1,140 @@
// frontend/src/modals/crm/customers/CreateCustomerModal.jsx
// On-the-fly customer creation with minimal fields — mirrors CreateUserModal.
// Everything beyond name/folder_id/organization/email can be filled in later
// on the full customer detail page.
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
function slugify(str) {
return str
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip accents
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export default function CreateCustomerModal({ open, onClose, onCreated }) {
const [name, setName] = useState('')
const [surname, setSurname] = useState('')
const [organization, setOrganization] = useState('')
const [email, setEmail] = useState('')
const [folderId, setFolderId] = useState('')
const [folderIdTouched, setFolderIdTouched] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
setName(''); setSurname(''); setOrganization(''); setEmail('')
setFolderId(''); setFolderIdTouched(false); setError('')
}, [open])
// Auto-suggest a folder slug from name+surname until the user edits it directly.
useEffect(() => {
if (folderIdTouched) return
setFolderId(slugify([name, surname].filter(Boolean).join('-')))
}, [name, surname, folderIdTouched])
const handleFolderIdChange = (e) => {
setFolderIdTouched(true)
setFolderId(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
}
const handleCreate = async () => {
setError('')
if (!name.trim()) { setError('First name is required.'); return }
const folder = folderId.trim()
if (!folder) { setError('Internal Folder ID is required.'); return }
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(folder)) {
setError('Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.')
return
}
setSaving(true)
try {
const created = await api.post('/crm/customers', {
name: name.trim(),
surname: surname.trim() || null,
organization: organization.trim() || null,
folder_id: folder,
relationship_status: 'lead',
contacts: email.trim() ? [{ type: 'email', label: '', value: email.trim(), primary: true }] : [],
})
onCreated(created)
} catch (err) {
setError(err.message || 'Failed to create customer.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Create Customer"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleCreate} loading={saving}>Create Customer</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField
label="First Name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="First name"
autoFocus
/>
<FormField
label="Surname"
name="surname"
type="text"
value={surname}
onChange={(e) => setSurname(e.target.value)}
placeholder="Last name"
/>
</div>
<FormField
label="Organization"
name="organization"
type="text"
value={organization}
onChange={(e) => setOrganization(e.target.value)}
placeholder="e.g. Saint John's Church"
/>
<FormField
label="Contact Email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
/>
<FormField
label="Internal Folder ID"
name="folder_id"
type="text"
value={folderId}
onChange={handleFolderIdChange}
required
placeholder="e.g. saint-john-corfu"
hint="Lowercase letters, numbers and hyphens only. Becomes the Nextcloud folder name and cannot be changed. Auto-filled from the name — edit if needed."
/>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -9,6 +9,7 @@ import DashboardPage from '@/pages/dashboard/DashboardPage'
import DeviceList from '@/pages/bellcloud/devices/DeviceList' import DeviceList from '@/pages/bellcloud/devices/DeviceList'
import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail' import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage' import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage'
import OnboardDevice from '@/pages/bellcloud/devices/OnboardDevice'
import StyleGuide from '@/pages/dev/StyleGuide' import StyleGuide from '@/pages/dev/StyleGuide'
import CardFontSample from '@/pages/dev/CardFontSample' import CardFontSample from '@/pages/dev/CardFontSample'
import UserList from '@/pages/bellcloud/users/UserList' import UserList from '@/pages/bellcloud/users/UserList'
@@ -145,6 +146,7 @@ export default function V2Router() {
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><ComingSoon /></PermissionGate>} /> <Route path="devices/new" element={<PermissionGate section="devices" action="add"><ComingSoon /></PermissionGate>} />
{/* Map preview — temporary unlisted route while map view is under construction */} {/* Map preview — temporary unlisted route while map view is under construction */}
<Route path="devices/map-preview" element={<PermissionGate section="devices"><DeviceMapPage /></PermissionGate>} /> <Route path="devices/map-preview" element={<PermissionGate section="devices"><DeviceMapPage /></PermissionGate>} />
<Route path="devices/onboard" element={<PermissionGate section="devices" action="edit"><OnboardDevice /></PermissionGate>} />
<Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} /> <Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><ComingSoon /></PermissionGate>} /> <Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><ComingSoon /></PermissionGate>} />