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:
@@ -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 */}
|
||||
<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)' }}>
|
||||
Assigned Users {userList.length > 0 && `(${userList.length})`}
|
||||
</p>
|
||||
{userList.length === 0 ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||
<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 }}>
|
||||
Assigned Users {deviceUsers.length > 0 && `(${deviceUsers.length})`}
|
||||
</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)' }}>
|
||||
No users assigned to this device.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{userList.map((uid) => {
|
||||
const resolved = resolvedUsers.find((u) => u.uid === uid)
|
||||
const displayName = resolved?.display_name || ''
|
||||
const email = resolved?.email || ''
|
||||
{deviceUsers.map((user) => {
|
||||
const uid = user.user_id
|
||||
const displayName = user.display_name || ''
|
||||
const email = user.email || ''
|
||||
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
||||
return (
|
||||
<div
|
||||
@@ -792,6 +837,24 @@ export default function DeviceInventoryDetail() {
|
||||
}
|
||||
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
||||
</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)" />
|
||||
</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 && (
|
||||
<LifecycleEditModal
|
||||
open={editModalData !== null}
|
||||
|
||||
Reference in New Issue
Block a user