Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx
|
||||
// Search and add an app user to a device's user_list.
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import api from '@/lib/api'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import Button from '@/components/ui/Button'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
|
||||
// ─── User row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function UserRow({ user, onAdd, adding }) {
|
||||
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
{/* Avatar */}
|
||||
<div style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-primary-subtle)',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-primary)',
|
||||
border: '1px solid rgba(192,193,255,0.20)',
|
||||
}}>
|
||||
{user.photo_url ? (
|
||||
<img src={user.photo_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : initials}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-primary)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{user.display_name || '—'}
|
||||
</p>
|
||||
{user.email && (
|
||||
<p style={{
|
||||
margin: 0,
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAdd(user)}
|
||||
loading={adding === user.id}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── AddDeviceUserModal ───────────────────────────────────────────────────────
|
||||
|
||||
export default function AddDeviceUserModal({ open, deviceId, existingUserIds = [], onClose, onAdded }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adding, setAdding] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
const debounceRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// Reset on open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setError('')
|
||||
setAdding(null)
|
||||
setTimeout(() => inputRef.current?.focus(), 80)
|
||||
}, [open])
|
||||
|
||||
const search = useCallback(async (q) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await api.get(`/devices/${deviceId}/user-search?q=${encodeURIComponent(q)}`)
|
||||
// Filter out users already on the device
|
||||
const existing = new Set(existingUserIds)
|
||||
setResults((data.results || []).filter(u => !existing.has(u.id)))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Search failed.')
|
||||
setResults([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [deviceId, existingUserIds])
|
||||
|
||||
const handleInput = (e) => {
|
||||
const val = e.target.value
|
||||
setQuery(val)
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => search(val), 280)
|
||||
}
|
||||
|
||||
// Load all users on open (empty query)
|
||||
useEffect(() => {
|
||||
if (open && deviceId) search('')
|
||||
}, [open, deviceId, search])
|
||||
|
||||
const handleAdd = async (user) => {
|
||||
setAdding(user.id)
|
||||
setError('')
|
||||
try {
|
||||
await api.post(`/devices/${deviceId}/user-list`, { user_id: user.id })
|
||||
onAdded(user)
|
||||
// Remove from results so it disappears immediately
|
||||
setResults(prev => prev.filter(u => u.id !== user.id))
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to add user.')
|
||||
} finally {
|
||||
setAdding(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title="Add User to Device" size="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleInput}
|
||||
placeholder="Search by name, email, or phone…"
|
||||
className="input"
|
||||
style={{ boxSizing: 'border-box' }}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-danger-bg)',
|
||||
border: '1px solid var(--color-danger)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--color-danger)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', minHeight: 80 }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
||||
<Spinner size="sm" />
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div style={{
|
||||
padding: 'var(--space-6)',
|
||||
textAlign: 'center',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
{query ? 'No users found matching your search.' : 'All users already added or no users exist.'}
|
||||
</div>
|
||||
) : (
|
||||
results.map(user => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
onAdd={handleAdd}
|
||||
adding={adding}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button variant="secondary" size="md" onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user