diff --git a/backend/manufacturing/models.py b/backend/manufacturing/models.py index f9ed93c..1eba160 100644 --- a/backend/manufacturing/models.py +++ b/backend/manufacturing/models.py @@ -80,6 +80,12 @@ class BatchResponse(BaseModel): created_at: str +class DeviceUserSummary(BaseModel): + user_id: str + display_name: str = "" + email: str = "" + + class DeviceInventoryItem(BaseModel): id: str serial_number: str @@ -91,9 +97,16 @@ class DeviceInventoryItem(BaseModel): owner: Optional[str] = None assigned_to: 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 customer_id: Optional[str] = None + customer_name: Optional[str] = None user_list: Optional[List[str]] = None + users: Optional[List[DeviceUserSummary]] = None class DeviceInventoryListResponse(BaseModel): diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index 835f375..84ff019 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -72,6 +72,8 @@ async def create_batch( def list_devices( status: 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), limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), @@ -80,6 +82,8 @@ def list_devices( items = service.list_devices( status=status, hw_type=hw_type, + hw_types=hw_types, + has_users=has_users, search=search, limit=limit, offset=offset, diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index e1b4ed5..ca0f653 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -11,9 +11,11 @@ from shared.firebase import get_db from shared.exceptions import NotFoundError from utils.serial_number import generate_serial 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" +CRM_COLLECTION = "crm_customers" +USERS_COLLECTION = "users" _BATCH_ID_CHARS = string.ascii_uppercase + string.digits @@ -45,7 +47,7 @@ def _resolve_user_list(raw_list: list) -> list[str]: 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 {} created_raw = data.get("created_at") if isinstance(created_raw, datetime): @@ -53,6 +55,14 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem: else: 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( id=doc.id, serial_number=data.get("serial_number", ""), @@ -64,12 +74,48 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem: owner=data.get("owner"), assigned_to=data.get("assigned_to"), device_name=data.get("device_name") or None, + console_name=data.get("console_name") or None, lifecycle_history=data.get("lifecycle_history") or [], - customer_id=data.get("customer_id"), - user_list=_resolve_user_list(data.get("user_list") or []), + customer_id=customer_id, + 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: db = get_db() existing_sns = _get_existing_sns(db) @@ -123,6 +169,8 @@ def create_batch(data: BatchCreate) -> BatchResponse: def list_devices( status: str | None = None, hw_type: str | None = None, + hw_types: list[str] | None = None, + has_users: bool | None = None, search: str | None = None, limit: int = 100, offset: int = 0, @@ -136,7 +184,24 @@ def list_devices( query = query.where("hw_type", "==", hw_type) 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: search_lower = search.lower() @@ -145,6 +210,10 @@ def list_devices( if search_lower in (item.serial_number 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.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] @@ -155,7 +224,9 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem: docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream()) if not docs: 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: diff --git a/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx b/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx index 5c3ab14..f1415cb 100644 --- a/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx +++ b/frontend/src/pages/engineering/manufacturing/DeviceInventoryDetail.jsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import api from '@/lib/api' import { useAuth } from '@/hooks/useAuth' +import { useToast } from '@/components/ui/Toast' import PageHeader from '@/components/ui/PageHeader' import Button from '@/components/ui/Button' import Card from '@/components/ui/Card' @@ -15,6 +16,7 @@ import FormField from '@/components/ui/FormField' import ConfirmDialog from '@/components/ui/ConfirmDialog' import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters' import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal' +import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal' // ─── Constants ──────────────────────────────────────────────────────────────── @@ -455,6 +457,7 @@ export default function DeviceInventoryDetail() { const { sn } = useParams() const navigate = useNavigate() const { hasPermission } = useAuth() + const { toast } = useToast() const canEdit = hasPermission('manufacturing', 'edit') const canDelete = hasPermission('manufacturing', 'delete') @@ -462,13 +465,19 @@ export default function DeviceInventoryDetail() { const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [assignedCustomer, setAssignedCustomer] = useState(null) - const [resolvedUsers, setResolvedUsers] = useState([]) + const [deviceUsers, setDeviceUsers] = useState([]) + const [usersLoading, setUsersLoading] = useState(false) // Assignment const [showCustomerModal, setShowCustomerModal] = useState(false) const [assignSaving, setAssignSaving] = useState(false) const [assignError, setAssignError] = useState('') + // User assignment + const [showAddUser, setShowAddUser] = useState(false) + const [removingUser, setRemovingUser] = useState(null) + const [confirmRemoveUser, setConfirmRemoveUser] = useState(null) + // Status const [statusSaving, setStatusSaving] = useState(false) const [statusError, setStatusError] = useState('') @@ -493,17 +502,6 @@ export default function DeviceInventoryDetail() { } else { 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) { setError(err.message) } finally { @@ -513,6 +511,42 @@ export default function DeviceInventoryDetail() { 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 ─────────────────────────────────────────────────────── const handleStatusChange = async (newStatus) => { @@ -612,7 +646,7 @@ export default function DeviceInventoryDetail() { } 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 editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false @@ -742,19 +776,30 @@ export default function DeviceInventoryDetail() { {/* User Assignment sub-section */}
- Assigned Users {userList.length > 0 && `(${userList.length})`} -
- {userList.length === 0 ? ( ++ Assigned Users {deviceUsers.length > 0 && `(${deviceUsers.length})`} +
+ {canEdit && ( + + )} +No users assigned to this device.
) : (