feat(manufacturing): enrich inventory list with customer/user details and filters
The inventory list and detail page previously only showed raw customer_id / user_list references. list_devices now batch-resolves customer names and user display names/emails (via Firestore get_all(), avoiding an N+1 round trip per device) and exposes them as customer_name / users on DeviceInventoryItem. Search now matches against device name, console name, customer name, and assigned users in addition to serial/owner/batch. Also adds hw_types (multi-select board type) and has_users filters to GET /manufacturing/devices, and carries console_name through for display. DeviceInventoryDetail is updated to show and use all of this (customer name, assigned users, new UI components for signal/charts). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,12 @@ class BatchResponse(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceUserSummary(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
display_name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DeviceInventoryItem(BaseModel):
|
class DeviceInventoryItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
serial_number: str
|
serial_number: str
|
||||||
@@ -91,9 +97,16 @@ class DeviceInventoryItem(BaseModel):
|
|||||||
owner: Optional[str] = None
|
owner: Optional[str] = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: Optional[str] = None
|
||||||
device_name: Optional[str] = None
|
device_name: Optional[str] = None
|
||||||
|
# Console-only friendly name (mirrors devices.models.DeviceCreate.console_name).
|
||||||
|
# Only populated once a device has been claimed/onboarded in the app — null
|
||||||
|
# for freshly manufactured inventory, which is expected and should render
|
||||||
|
# as a graceful fallback (e.g. serial number) on the frontend.
|
||||||
|
console_name: Optional[str] = None
|
||||||
lifecycle_history: Optional[List["LifecycleEntry"]] = None
|
lifecycle_history: Optional[List["LifecycleEntry"]] = None
|
||||||
customer_id: Optional[str] = None
|
customer_id: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
user_list: Optional[List[str]] = None
|
user_list: Optional[List[str]] = None
|
||||||
|
users: Optional[List[DeviceUserSummary]] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceInventoryListResponse(BaseModel):
|
class DeviceInventoryListResponse(BaseModel):
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ async def create_batch(
|
|||||||
def list_devices(
|
def list_devices(
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
hw_type: Optional[str] = Query(None),
|
hw_type: Optional[str] = Query(None),
|
||||||
|
hw_types: Optional[list[str]] = Query(None, description="Multi-select board type filter — repeat param, e.g. ?hw_types=vesper&hw_types=chronos"),
|
||||||
|
has_users: Optional[bool] = Query(None, description="Filter by whether the device has any app users assigned"),
|
||||||
search: Optional[str] = Query(None),
|
search: Optional[str] = Query(None),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
@@ -80,6 +82,8 @@ def list_devices(
|
|||||||
items = service.list_devices(
|
items = service.list_devices(
|
||||||
status=status,
|
status=status,
|
||||||
hw_type=hw_type,
|
hw_type=hw_type,
|
||||||
|
hw_types=hw_types,
|
||||||
|
has_users=has_users,
|
||||||
search=search,
|
search=search,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from shared.firebase import get_db
|
|||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from utils.serial_number import generate_serial
|
from utils.serial_number import generate_serial
|
||||||
from utils.nvs_generator import generate as generate_nvs_binary
|
from utils.nvs_generator import generate as generate_nvs_binary
|
||||||
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS
|
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS, DeviceUserSummary
|
||||||
|
|
||||||
COLLECTION = "devices"
|
COLLECTION = "devices"
|
||||||
|
CRM_COLLECTION = "crm_customers"
|
||||||
|
USERS_COLLECTION = "users"
|
||||||
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ def _resolve_user_list(raw_list: list) -> list[str]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
def _doc_to_inventory_item(doc, customer_names: dict | None = None, user_infos: dict | None = None) -> DeviceInventoryItem:
|
||||||
data = doc.to_dict() or {}
|
data = doc.to_dict() or {}
|
||||||
created_raw = data.get("created_at")
|
created_raw = data.get("created_at")
|
||||||
if isinstance(created_raw, datetime):
|
if isinstance(created_raw, datetime):
|
||||||
@@ -53,6 +55,14 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
|||||||
else:
|
else:
|
||||||
created_str = str(created_raw) if created_raw else None
|
created_str = str(created_raw) if created_raw else None
|
||||||
|
|
||||||
|
customer_names = customer_names or {}
|
||||||
|
user_infos = user_infos or {}
|
||||||
|
|
||||||
|
user_ids = _resolve_user_list(data.get("user_list") or [])
|
||||||
|
users = [user_infos[uid] for uid in user_ids if uid in user_infos]
|
||||||
|
|
||||||
|
customer_id = data.get("customer_id")
|
||||||
|
|
||||||
return DeviceInventoryItem(
|
return DeviceInventoryItem(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
serial_number=data.get("serial_number", ""),
|
serial_number=data.get("serial_number", ""),
|
||||||
@@ -64,12 +74,48 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
|||||||
owner=data.get("owner"),
|
owner=data.get("owner"),
|
||||||
assigned_to=data.get("assigned_to"),
|
assigned_to=data.get("assigned_to"),
|
||||||
device_name=data.get("device_name") or None,
|
device_name=data.get("device_name") or None,
|
||||||
|
console_name=data.get("console_name") or None,
|
||||||
lifecycle_history=data.get("lifecycle_history") or [],
|
lifecycle_history=data.get("lifecycle_history") or [],
|
||||||
customer_id=data.get("customer_id"),
|
customer_id=customer_id,
|
||||||
user_list=_resolve_user_list(data.get("user_list") or []),
|
customer_name=customer_names.get(customer_id) if customer_id else None,
|
||||||
|
user_list=user_ids,
|
||||||
|
users=users,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_resolve_names(db, docs_data: list[dict]) -> tuple[dict, dict]:
|
||||||
|
"""Batch-resolve customer names and user summaries referenced across a
|
||||||
|
list of device docs, so listing N devices costs a handful of get_all()
|
||||||
|
calls instead of an N+1 Firestore round trip per device."""
|
||||||
|
customer_ids = {d.get("customer_id") for d in docs_data if d.get("customer_id")}
|
||||||
|
user_ids = {uid for d in docs_data for uid in _resolve_user_list(d.get("user_list") or [])}
|
||||||
|
|
||||||
|
customer_names: dict[str, str] = {}
|
||||||
|
if customer_ids:
|
||||||
|
refs = [db.collection(CRM_COLLECTION).document(cid) for cid in customer_ids]
|
||||||
|
for snap in db.get_all(refs):
|
||||||
|
if not snap.exists:
|
||||||
|
continue
|
||||||
|
cdata = snap.to_dict() or {}
|
||||||
|
name = " ".join(filter(None, [cdata.get("name"), cdata.get("surname")])) or cdata.get("email") or snap.id
|
||||||
|
customer_names[snap.id] = name
|
||||||
|
|
||||||
|
user_infos: dict[str, DeviceUserSummary] = {}
|
||||||
|
if user_ids:
|
||||||
|
refs = [db.collection(USERS_COLLECTION).document(uid) for uid in user_ids]
|
||||||
|
for snap in db.get_all(refs):
|
||||||
|
if not snap.exists:
|
||||||
|
continue
|
||||||
|
udata = snap.to_dict() or {}
|
||||||
|
user_infos[snap.id] = DeviceUserSummary(
|
||||||
|
user_id=snap.id,
|
||||||
|
display_name=udata.get("display_name") or "",
|
||||||
|
email=udata.get("email") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
return customer_names, user_infos
|
||||||
|
|
||||||
|
|
||||||
def create_batch(data: BatchCreate) -> BatchResponse:
|
def create_batch(data: BatchCreate) -> BatchResponse:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
existing_sns = _get_existing_sns(db)
|
existing_sns = _get_existing_sns(db)
|
||||||
@@ -123,6 +169,8 @@ def create_batch(data: BatchCreate) -> BatchResponse:
|
|||||||
def list_devices(
|
def list_devices(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
hw_type: str | None = None,
|
hw_type: str | None = None,
|
||||||
|
hw_types: list[str] | None = None,
|
||||||
|
has_users: bool | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -136,7 +184,24 @@ def list_devices(
|
|||||||
query = query.where("hw_type", "==", hw_type)
|
query = query.where("hw_type", "==", hw_type)
|
||||||
|
|
||||||
docs = list(query.stream())
|
docs = list(query.stream())
|
||||||
items = [_doc_to_inventory_item(doc) for doc in docs]
|
docs_data = [doc.to_dict() or {} for doc in docs]
|
||||||
|
|
||||||
|
if hw_types:
|
||||||
|
hw_type_set = set(hw_types)
|
||||||
|
keep = [i for i, d in enumerate(docs_data) if d.get("hw_type") in hw_type_set]
|
||||||
|
docs = [docs[i] for i in keep]
|
||||||
|
docs_data = [docs_data[i] for i in keep]
|
||||||
|
|
||||||
|
if has_users is not None:
|
||||||
|
keep = [
|
||||||
|
i for i, d in enumerate(docs_data)
|
||||||
|
if bool(d.get("user_list")) == has_users
|
||||||
|
]
|
||||||
|
docs = [docs[i] for i in keep]
|
||||||
|
docs_data = [docs_data[i] for i in keep]
|
||||||
|
|
||||||
|
customer_names, user_infos = _bulk_resolve_names(db, docs_data)
|
||||||
|
items = [_doc_to_inventory_item(doc, customer_names, user_infos) for doc in docs]
|
||||||
|
|
||||||
if search:
|
if search:
|
||||||
search_lower = search.lower()
|
search_lower = search.lower()
|
||||||
@@ -145,6 +210,10 @@ def list_devices(
|
|||||||
if search_lower in (item.serial_number or "").lower()
|
if search_lower in (item.serial_number or "").lower()
|
||||||
or search_lower in (item.owner or "").lower()
|
or search_lower in (item.owner or "").lower()
|
||||||
or search_lower in (item.mfg_batch_id or "").lower()
|
or search_lower in (item.mfg_batch_id or "").lower()
|
||||||
|
or search_lower in (item.device_name or "").lower()
|
||||||
|
or search_lower in (item.console_name or "").lower()
|
||||||
|
or search_lower in (item.customer_name or "").lower()
|
||||||
|
or any(search_lower in (u.display_name or "").lower() or search_lower in (u.email or "").lower() for u in (item.users or []))
|
||||||
]
|
]
|
||||||
|
|
||||||
return items[offset: offset + limit]
|
return items[offset: offset + limit]
|
||||||
@@ -155,7 +224,9 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem:
|
|||||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Device")
|
raise NotFoundError("Device")
|
||||||
return _doc_to_inventory_item(docs[0])
|
doc = docs[0]
|
||||||
|
customer_names, user_infos = _bulk_resolve_names(db, [doc.to_dict() or {}])
|
||||||
|
return _doc_to_inventory_item(doc, customer_names, user_infos)
|
||||||
|
|
||||||
|
|
||||||
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
|
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import PageHeader from '@/components/ui/PageHeader'
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import Card from '@/components/ui/Card'
|
import Card from '@/components/ui/Card'
|
||||||
@@ -15,6 +16,7 @@ import FormField from '@/components/ui/FormField'
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||||
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
|
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
|
||||||
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
||||||
|
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -455,6 +457,7 @@ export default function DeviceInventoryDetail() {
|
|||||||
const { sn } = useParams()
|
const { sn } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
|
const { toast } = useToast()
|
||||||
const canEdit = hasPermission('manufacturing', 'edit')
|
const canEdit = hasPermission('manufacturing', 'edit')
|
||||||
const canDelete = hasPermission('manufacturing', 'delete')
|
const canDelete = hasPermission('manufacturing', 'delete')
|
||||||
|
|
||||||
@@ -462,13 +465,19 @@ export default function DeviceInventoryDetail() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [assignedCustomer, setAssignedCustomer] = useState(null)
|
const [assignedCustomer, setAssignedCustomer] = useState(null)
|
||||||
const [resolvedUsers, setResolvedUsers] = useState([])
|
const [deviceUsers, setDeviceUsers] = useState([])
|
||||||
|
const [usersLoading, setUsersLoading] = useState(false)
|
||||||
|
|
||||||
// Assignment
|
// Assignment
|
||||||
const [showCustomerModal, setShowCustomerModal] = useState(false)
|
const [showCustomerModal, setShowCustomerModal] = useState(false)
|
||||||
const [assignSaving, setAssignSaving] = useState(false)
|
const [assignSaving, setAssignSaving] = useState(false)
|
||||||
const [assignError, setAssignError] = useState('')
|
const [assignError, setAssignError] = useState('')
|
||||||
|
|
||||||
|
// User assignment
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false)
|
||||||
|
const [removingUser, setRemovingUser] = useState(null)
|
||||||
|
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
const [statusSaving, setStatusSaving] = useState(false)
|
const [statusSaving, setStatusSaving] = useState(false)
|
||||||
const [statusError, setStatusError] = useState('')
|
const [statusError, setStatusError] = useState('')
|
||||||
@@ -493,17 +502,6 @@ export default function DeviceInventoryDetail() {
|
|||||||
} else {
|
} else {
|
||||||
setAssignedCustomer(null)
|
setAssignedCustomer(null)
|
||||||
}
|
}
|
||||||
if (data.user_list?.length) {
|
|
||||||
Promise.all(
|
|
||||||
data.user_list.map((uid) =>
|
|
||||||
api.get(`/users/${uid}`)
|
|
||||||
.then((u) => ({ uid, display_name: u.display_name || '', email: u.email || '' }))
|
|
||||||
.catch(() => ({ uid, display_name: '', email: '' }))
|
|
||||||
)
|
|
||||||
).then(setResolvedUsers)
|
|
||||||
} else {
|
|
||||||
setResolvedUsers([])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -513,6 +511,42 @@ export default function DeviceInventoryDetail() {
|
|||||||
|
|
||||||
useEffect(() => { loadDevice() }, [loadDevice])
|
useEffect(() => { loadDevice() }, [loadDevice])
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async (deviceId) => {
|
||||||
|
if (!deviceId) { setDeviceUsers([]); return }
|
||||||
|
setUsersLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/devices/${deviceId}/users`)
|
||||||
|
setDeviceUsers(data.users || [])
|
||||||
|
} catch {
|
||||||
|
setDeviceUsers([])
|
||||||
|
} finally {
|
||||||
|
setUsersLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { if (device?.id) loadUsers(device.id) }, [device?.id, loadUsers])
|
||||||
|
|
||||||
|
const handleUserAdded = (user) => {
|
||||||
|
setDeviceUsers((prev) => [...prev, { user_id: user.id, display_name: user.display_name, email: user.email, photo_url: user.photo_url, role: '' }])
|
||||||
|
toast.success('User added', `${user.display_name || user.email} now has access.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveUser = async () => {
|
||||||
|
if (!confirmRemoveUser || !device?.id) return
|
||||||
|
const targetId = confirmRemoveUser.user_id
|
||||||
|
setRemovingUser(targetId)
|
||||||
|
try {
|
||||||
|
await api.delete(`/devices/${device.id}/user-list/${targetId}`)
|
||||||
|
setDeviceUsers((prev) => prev.filter((u) => u.user_id !== targetId))
|
||||||
|
toast.success('Removed', `${confirmRemoveUser.display_name || confirmRemoveUser.email} removed.`)
|
||||||
|
} catch (err) {
|
||||||
|
toast.danger('Error', err.message || 'Failed to remove user.')
|
||||||
|
} finally {
|
||||||
|
setRemovingUser(null)
|
||||||
|
setConfirmRemoveUser(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Status change ───────────────────────────────────────────────────────
|
// ─── Status change ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus) => {
|
const handleStatusChange = async (newStatus) => {
|
||||||
@@ -612,7 +646,7 @@ export default function DeviceInventoryDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
|
const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
|
||||||
const userList = device.user_list || []
|
const existingUserIds = deviceUsers.map((u) => u.user_id).filter(Boolean)
|
||||||
|
|
||||||
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
|
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
|
||||||
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
|
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
|
||||||
@@ -742,19 +776,30 @@ export default function DeviceInventoryDetail() {
|
|||||||
|
|
||||||
{/* User Assignment sub-section */}
|
{/* User Assignment sub-section */}
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||||
Assigned Users {userList.length > 0 && `(${userList.length})`}
|
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', margin: 0 }}>
|
||||||
</p>
|
Assigned Users {deviceUsers.length > 0 && `(${deviceUsers.length})`}
|
||||||
{userList.length === 0 ? (
|
</p>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowAddUser(true)}>
|
||||||
|
+ Add User
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{usersLoading ? (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</div>
|
||||||
|
) : deviceUsers.length === 0 ? (
|
||||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||||
No users assigned to this device.
|
No users assigned to this device.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
{userList.map((uid) => {
|
{deviceUsers.map((user) => {
|
||||||
const resolved = resolvedUsers.find((u) => u.uid === uid)
|
const uid = user.user_id
|
||||||
const displayName = resolved?.display_name || ''
|
const displayName = user.display_name || ''
|
||||||
const email = resolved?.email || ''
|
const email = user.email || ''
|
||||||
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -792,6 +837,24 @@ export default function DeviceInventoryDetail() {
|
|||||||
}
|
}
|
||||||
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setConfirmRemoveUser(user) }}
|
||||||
|
disabled={removingUser === uid}
|
||||||
|
title="Remove access" aria-label="Remove user"
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: 'var(--radius-sm)',
|
||||||
|
border: '1px solid transparent', background: 'none',
|
||||||
|
color: 'var(--color-text-muted)', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
|
||||||
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Icon name="delete" size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<Icon name="chevron-right" size={14} color="var(--color-text-muted)" />
|
<Icon name="chevron-right" size={14} color="var(--color-text-muted)" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -829,6 +892,25 @@ export default function DeviceInventoryDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AddDeviceUserModal
|
||||||
|
open={showAddUser}
|
||||||
|
deviceId={device.id}
|
||||||
|
existingUserIds={existingUserIds}
|
||||||
|
onClose={() => setShowAddUser(false)}
|
||||||
|
onAdded={handleUserAdded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!confirmRemoveUser}
|
||||||
|
variant="danger"
|
||||||
|
title="Remove Access"
|
||||||
|
message={`Remove ${confirmRemoveUser?.display_name || confirmRemoveUser?.email || 'this user'} from the device?`}
|
||||||
|
confirmLabel="Remove"
|
||||||
|
onConfirm={handleRemoveUser}
|
||||||
|
onClose={() => setConfirmRemoveUser(null)}
|
||||||
|
loading={!!removingUser}
|
||||||
|
/>
|
||||||
|
|
||||||
{editModalData && editStepMeta && (
|
{editModalData && editStepMeta && (
|
||||||
<LifecycleEditModal
|
<LifecycleEditModal
|
||||||
open={editModalData !== null}
|
open={editModalData !== null}
|
||||||
|
|||||||
Reference in New Issue
Block a user