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>,
|
||||
blackBox: () => <SvgIcon Component={IcoBlackbox} />,
|
||||
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>,
|
||||
melodies: () => <SvgIcon Component={IcoMelodies} />,
|
||||
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
|
||||
@@ -132,6 +133,7 @@ const navSections = [
|
||||
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
|
||||
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
|
||||
{ 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' },
|
||||
|
||||
@@ -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 DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
|
||||
import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage'
|
||||
import OnboardDevice from '@/pages/bellcloud/devices/OnboardDevice'
|
||||
import StyleGuide from '@/pages/dev/StyleGuide'
|
||||
import CardFontSample from '@/pages/dev/CardFontSample'
|
||||
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>} />
|
||||
{/* 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/onboard" element={<PermissionGate section="devices" action="edit"><OnboardDevice /></PermissionGate>} />
|
||||
<Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
|
||||
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><ComingSoon /></PermissionGate>} />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user