Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
@@ -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>
)
}