// frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
// Manage tab — Device Users + Device Notes + Linked Issues
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import Icon from '@/components/ui/Icon'
import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
// ─── Visual constants (match OverviewTab's GlassCard aesthetic) ───────────────
const GLASS = 'rgba(28, 32, 38, 0.30)'
const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
const GLASS_HOVER = 'rgba(49, 53, 60, 0.55)'
const BLUR = 'blur(12px)'
// ─── Status/severity maps (match IssuesTab constants) ─────────────────────────
const STATUS_META = {
open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
}
const SEVERITY_META = {
low: { label: 'Low', color: 'var(--color-info)' },
medium: { label: 'Medium', color: 'var(--color-warning)' },
high: { label: 'High', color: 'var(--color-danger)' },
critical: { label: 'Critical', color: 'var(--color-danger)', breathe: true },
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function SectionLabel({ children }) {
return (
{children}
)
}
function GlassCard({ children, style = {} }) {
return (
{children}
)
}
function CardHeader({ label, count, action }) {
return (
{label}
{count != null && (
{count}
)}
{action}
)
}
function EmptySlate({ icon, message, action }) {
return (
{icon}
{message}
{action}
)
}
function StatusPill({ status }) {
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
return (
{m.label}
)
}
function SeverityBars({ severity }) {
if (!severity) return null
const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)' }
const bars = { low: 1, medium: 2, high: 3, critical: 4 }[severity] || 1
return (
{[1, 2, 3, 4].map(b => (
))}
{m.label}
)
}
// ─── ManageTab ────────────────────────────────────────────────────────────────
export default function ManageTab({ device, canEdit, deviceUsers: propUsers, usersLoading: propUsersLoading }) {
const navigate = useNavigate()
const { toast } = useToast()
const id = device?.id || device?.device_id
// ── Users state ────────────────────────────────────────────────────────────
// We manage our own copy so we can add/remove without refetching the entire device
const [users, setUsers] = useState(propUsers || [])
const [usersLoading, setUsersLoading] = useState(propUsersLoading || false)
const [removingUser, setRemovingUser] = useState(null)
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
const [showAddUser, setShowAddUser] = useState(false)
// Sync from parent prop on mount / prop change
useEffect(() => {
if (propUsers) setUsers(propUsers)
}, [propUsers])
useEffect(() => {
setUsersLoading(propUsersLoading)
}, [propUsersLoading])
const reloadUsers = useCallback(async () => {
if (!id) return
setUsersLoading(true)
try {
const data = await api.get(`/devices/${id}/users`)
setUsers(data.users || [])
} catch {
// silent — parent has already shown this device's users
} finally {
setUsersLoading(false)
}
}, [id])
const handleUserAdded = (user) => {
setUsers(prev => [...prev, user])
toast.success('User added', `${user.display_name || user.email} now has access.`)
}
const handleRemoveUser = async () => {
if (!confirmRemoveUser) return
const targetId = confirmRemoveUser.user_id || confirmRemoveUser.id
setRemovingUser(targetId)
try {
await api.delete(`/devices/${id}/user-list/${targetId}`)
setUsers(prev => prev.filter(u => (u.user_id || u.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)
}
}
// ── Device notes (Firestore) ──────────────────────────────────────────────
const [notes, setNotes] = useState([])
const [notesLoading, setNotesLoading] = useState(false)
const [noteModal, setNoteModal] = useState({ open: false, note: null })
const [deletingNote, setDeletingNote] = useState(null)
const [confirmDeleteNote, setConfirmDeleteNote] = useState(null)
const loadNotes = useCallback(async () => {
if (!id) return
setNotesLoading(true)
try {
const data = await api.get(`/devices/${id}/notes`)
setNotes((data.notes || []).slice().reverse()) // newest first
} catch {
setNotes([])
} finally {
setNotesLoading(false)
}
}, [id])
useEffect(() => { loadNotes() }, [loadNotes])
const handleNoteSaved = () => {
setNoteModal({ open: false, note: null })
loadNotes()
}
const handleDeleteNote = async () => {
if (!confirmDeleteNote) return
setDeletingNote(confirmDeleteNote.id)
try {
await api.delete(`/devices/${id}/notes/${confirmDeleteNote.id}`)
setNotes(prev => prev.filter(n => n.id !== confirmDeleteNote.id))
toast.success('Deleted', 'Note removed.')
} catch (err) {
toast.danger('Error', err.message || 'Failed to delete note.')
} finally {
setDeletingNote(null)
setConfirmDeleteNote(null)
}
}
// ── Linked issues (global /notes system, entity_type=device) ─────────────
const [issues, setIssues] = useState([])
const [issuesLoading, setIssuesLoading] = useState(false)
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
const [confirmDeleteIssue, setConfirmDeleteIssue] = useState(null)
const [deletingIssue, setDeletingIssue] = useState(false)
const loadIssues = useCallback(async () => {
if (!id) return
setIssuesLoading(true)
try {
const data = await api.get(`/notes/by-entity/device/${id}`)
setIssues(Array.isArray(data) ? data : [])
} catch {
setIssues([])
} finally {
setIssuesLoading(false)
}
}, [id])
useEffect(() => { loadIssues() }, [loadIssues])
const openNewIssue = () => setIssueModal({ open: true, entry: null })
const openEditIssue = (entry) => setIssueModal({ open: true, entry })
const handleIssueSaved = () => {
setIssueModal({ open: false, entry: null })
loadIssues()
}
const handleDeleteIssue = async () => {
if (!confirmDeleteIssue) return
setDeletingIssue(true)
try {
await api.delete(`/notes/${confirmDeleteIssue}`)
toast.success('Deleted', 'Issue removed.')
setConfirmDeleteIssue(null)
loadIssues()
} catch (err) {
toast.danger('Error', err.message || 'Failed to delete issue.')
} finally {
setDeletingIssue(false)
}
}
const sn = device?.serial_number || device?.device_id
const existingUserIds = users.map(u => u.user_id || u.id).filter(Boolean)
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* ── 1. DEVICE ACCESS (Users) ──────────────────────────────────────── */}
setShowAddUser(true)}>
+ Add User
)}
/>
{usersLoading ? (
) : users.length === 0 ? (
}
message="No users have access to this device."
action={canEdit && (
)}
/>
) : (
users.map((user) => {
const userId = user.user_id || user.id
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
return (
userId && navigate(`/users/${userId}`)}
onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
>
{user.photo_url ?

: initials}
{user.display_name || user.email || 'Unknown User'}
{user.email && user.display_name && (
{user.email}
)}
{user.role &&
{user.role}}
{canEdit && (
)}
)
})
)}
{/* ── 2. LINKED ISSUES ──────────────────────────────────────────────── */}
i.status !== 'resolved').length > 0
? `${issues.filter(i => i.status !== 'resolved').length} open`
: issues.length > 0 ? `${issues.length} total` : undefined
}
action={canEdit && (
)}
/>
{issuesLoading ? (
) : issues.length === 0 ? (
}
message="No issues linked to this device."
action={canEdit && (
)}
/>
) : (
issues.map((issue, idx) => (
canEdit && openEditIssue(issue)}
>
{issue.title}
{issue.body && (
{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
)}
{fmtRelative(issue.created_at)}
{canEdit && (
)}
))
)}
{/* ── 3. DEVICE NOTES ───────────────────────────────────────────────── */}
setNoteModal({ open: true, note: null })}>
+ Add Note
)}
/>
{notesLoading ? (
) : notes.length === 0 ? (
}
message="No notes recorded for this device yet."
action={canEdit && (
)}
/>
) : (
{notes.map(note => (
{note.content || '—'}
{fmtRelative(note.created_at)}
{note.created_by && (
{note.created_by}
)}
{canEdit && (
)}
))}
)}
{/* ── Modals ─────────────────────────────────────────────────────────── */}
setShowAddUser(false)}
onAdded={handleUserAdded}
/>
setNoteModal({ open: false, note: null })}
onSaved={handleNoteSaved}
/>
{/* Issue form — pre-link to this device (locked — cannot be removed) */}
setIssueModal({ open: false, entry: null })}
onSaved={handleIssueSaved}
onDelete={canEdit ? (entryId) => {
setIssueModal({ open: false, entry: null })
setConfirmDeleteIssue(entryId)
} : null}
/>
{/* Confirm: remove user */}
setConfirmRemoveUser(null)}
loading={!!removingUser}
/>
{/* Confirm: delete note */}
setConfirmDeleteNote(null)}
loading={deletingNote === confirmDeleteNote?.id}
/>
{/* Confirm: delete issue */}
setConfirmDeleteIssue(null)}
loading={deletingIssue}
/>
>
)
}