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:
@@ -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
@@ -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>} />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user