// 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. 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 && (
)}
))}
)}
{/* ── 3. 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 && ( )}
)) )}
{/* ── 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} /> ) }