Lets staff set a friendly name for a device independent of whatever name the end user gave it in the app. console_name is never shown to app users and never synced from/to device_name; every display label across search, equipment/helpdesk name resolution, device search, and the Manage tab's issue linker now falls back through console_name -> device_name -> serial rather than device_name alone. Also includes an incidental one-line fix in devices/service.py: the nested-struct deep-merge in update_device() was missing the newly added device_health_settings key. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
684 lines
30 KiB
React
684 lines
30 KiB
React
// 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 (
|
|
<span style={{
|
|
fontSize: 'var(--font-size-xs)',
|
|
fontWeight: 'var(--font-weight-semibold)',
|
|
color: 'var(--color-text-muted)',
|
|
letterSpacing: 'var(--tracking-wide)',
|
|
textTransform: 'uppercase',
|
|
}}>
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function GlassCard({ children, style = {} }) {
|
|
return (
|
|
<div style={{
|
|
background: GLASS,
|
|
backdropFilter: BLUR,
|
|
WebkitBackdropFilter: BLUR,
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-xl)',
|
|
boxShadow: 'var(--shadow-card), var(--shadow-md)',
|
|
...style,
|
|
}}>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CardHeader({ label, count, action }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
padding: 'var(--space-4) var(--space-5)',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
}}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
|
<SectionLabel>{label}</SectionLabel>
|
|
{count != null && (
|
|
<span style={{
|
|
fontSize: 'var(--font-size-xs)',
|
|
color: 'var(--color-text-muted)',
|
|
background: GLASS_INNER,
|
|
padding: '2px var(--space-2)',
|
|
borderRadius: 'var(--radius-full)',
|
|
border: '1px solid var(--color-border)',
|
|
lineHeight: 1.4,
|
|
}}>
|
|
{count}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{action}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function EmptySlate({ icon, message, action }) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3)',
|
|
padding: 'var(--space-8) var(--space-6)',
|
|
color: 'var(--color-text-muted)',
|
|
}}>
|
|
<div style={{ opacity: 0.35 }}>{icon}</div>
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>{message}</p>
|
|
{action}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatusPill({ status }) {
|
|
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
|
|
return (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
|
padding: '2px 8px',
|
|
borderRadius: 'var(--radius-full)',
|
|
backgroundColor: m.bg,
|
|
border: `1px solid ${m.color}33`,
|
|
fontSize: 'var(--font-size-xs)',
|
|
fontWeight: 'var(--font-weight-semibold)',
|
|
color: m.color,
|
|
whiteSpace: 'nowrap',
|
|
flexShrink: 0,
|
|
}}>
|
|
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: m.color, flexShrink: 0 }} />
|
|
{m.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<span
|
|
className={m.breathe ? 'severity-critical' : undefined}
|
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}
|
|
>
|
|
<span style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 12 }}>
|
|
{[1, 2, 3, 4].map(b => (
|
|
<span key={b} style={{
|
|
width: 3,
|
|
height: 2 + b * 2,
|
|
borderRadius: 1,
|
|
backgroundColor: b <= bars ? m.color : 'var(--color-bg-island)',
|
|
display: 'block',
|
|
}} />
|
|
))}
|
|
</span>
|
|
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: m.color }}>
|
|
{m.label}
|
|
</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<>
|
|
<style>{`
|
|
.manage-user-row:hover .manage-user-remove { opacity: 1; }
|
|
.manage-user-row .manage-user-remove { opacity: 0; transition: opacity 0.15s; }
|
|
.manage-issue-row { cursor: pointer; transition: background 0.12s; }
|
|
.manage-issue-row:hover { background: ${GLASS_HOVER} !important; }
|
|
.manage-note-card { transition: box-shadow 0.15s; }
|
|
.manage-note-card:hover { box-shadow: var(--shadow-md) !important; }
|
|
@keyframes severity-critical-glow {
|
|
0%, 100% { filter: drop-shadow(0 0 0px var(--color-danger)); }
|
|
50% { filter: drop-shadow(0 0 5px var(--color-danger)); }
|
|
}
|
|
.severity-critical { animation: severity-critical-glow 0.9s ease-in-out infinite; }
|
|
`}</style>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
|
|
|
{/* ── 1. DEVICE ACCESS (Users) ──────────────────────────────────────── */}
|
|
<GlassCard>
|
|
<CardHeader
|
|
label="Device Access"
|
|
count={users.length || undefined}
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>
|
|
+ Add User
|
|
</Button>
|
|
)}
|
|
/>
|
|
|
|
<div style={{ padding: 'var(--space-4) var(--space-5)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
|
{usersLoading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
|
<Spinner size="sm" />
|
|
</div>
|
|
) : users.length === 0 ? (
|
|
<EmptySlate
|
|
icon={
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
}
|
|
message="No users have access to this device."
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>Add first user</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
users.map((user) => {
|
|
const userId = user.user_id || user.id
|
|
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
|
|
return (
|
|
<div
|
|
key={userId}
|
|
className="manage-user-row"
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
|
|
padding: 'var(--space-3)',
|
|
borderRadius: 'var(--radius-md)',
|
|
background: GLASS_INNER,
|
|
border: '1px solid var(--color-border)',
|
|
cursor: userId ? 'pointer' : 'default',
|
|
transition: 'background 0.15s',
|
|
}}
|
|
onClick={() => userId && navigate(`/users/${userId}`)}
|
|
onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
|
|
onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
|
|
>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: '50%', overflow: 'hidden',
|
|
background: 'var(--color-primary-subtle)', flexShrink: 0,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
|
|
color: 'var(--color-primary)', border: '1px solid rgba(192,193,255,0.20)',
|
|
}}>
|
|
{user.photo_url ? <img src={user.photo_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : initials}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{user.display_name || user.email || 'Unknown User'}
|
|
</p>
|
|
{user.email && user.display_name && (
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{user.email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{user.role && <StatusBadge variant="info">{user.role}</StatusBadge>}
|
|
{canEdit && (
|
|
<button
|
|
className="manage-user-remove"
|
|
onClick={e => { e.stopPropagation(); setConfirmRemoveUser(user) }}
|
|
disabled={removingUser === userId}
|
|
title="Remove access" aria-label="Remove user"
|
|
style={{
|
|
width: 28, height: 28, borderRadius: 'var(--radius-md)',
|
|
border: '1px solid var(--color-border-strong)',
|
|
background: 'var(--color-danger-bg)', color: 'var(--color-danger)',
|
|
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
flexShrink: 0, transition: 'background 0.12s',
|
|
}}
|
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-danger)'}
|
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--color-danger-bg)'}
|
|
>
|
|
<Icon name="delete" size={13} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* ── 2. LINKED ISSUES ──────────────────────────────────────────────── */}
|
|
<GlassCard>
|
|
<CardHeader
|
|
label="Linked Issues"
|
|
count={
|
|
issues.filter(i => i.status !== 'resolved').length > 0
|
|
? `${issues.filter(i => i.status !== 'resolved').length} open`
|
|
: issues.length > 0 ? `${issues.length} total` : undefined
|
|
}
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={openNewIssue}>
|
|
+ New Issue
|
|
</Button>
|
|
)}
|
|
/>
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
|
{issuesLoading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
|
<Spinner size="sm" />
|
|
</div>
|
|
) : issues.length === 0 ? (
|
|
<EmptySlate
|
|
icon={
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
}
|
|
message="No issues linked to this device."
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={openNewIssue}>Log first issue</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
issues.map((issue, idx) => (
|
|
<div
|
|
key={issue.id}
|
|
className="manage-issue-row"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: 'auto 1fr auto auto auto',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-3)',
|
|
padding: 'var(--space-3) var(--space-5)',
|
|
borderBottom: idx < issues.length - 1 ? '1px solid var(--color-border)' : 'none',
|
|
}}
|
|
onClick={() => canEdit && openEditIssue(issue)}
|
|
>
|
|
<StatusPill status={issue.status} />
|
|
|
|
<div style={{ minWidth: 0 }}>
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{issue.title}
|
|
</p>
|
|
{issue.body && (
|
|
<p style={{ margin: '2px 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<SeverityBars severity={issue.severity} />
|
|
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }} title={fmtDateMedium(issue.created_at)}>
|
|
{fmtRelative(issue.created_at)}
|
|
</span>
|
|
|
|
{canEdit && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); setConfirmDeleteIssue(issue.id) }}
|
|
title="Delete issue" aria-label="Delete issue"
|
|
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>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* ── 3. DEVICE NOTES ───────────────────────────────────────────────── */}
|
|
<GlassCard>
|
|
<CardHeader
|
|
label="Device Notes"
|
|
count={notes.length || undefined}
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
|
|
+ Add Note
|
|
</Button>
|
|
)}
|
|
/>
|
|
|
|
<div style={{ padding: notesLoading || notes.length === 0 ? 0 : 'var(--space-4) var(--space-5)' }}>
|
|
{notesLoading ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
|
<Spinner size="sm" />
|
|
</div>
|
|
) : notes.length === 0 ? (
|
|
<EmptySlate
|
|
icon={
|
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14 2 14 8 20 8"/>
|
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
<polyline points="10 9 9 9 8 9"/>
|
|
</svg>
|
|
}
|
|
message="No notes recorded for this device yet."
|
|
action={canEdit && (
|
|
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
|
|
Add first note
|
|
</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
|
{notes.map(note => (
|
|
<div
|
|
key={note.id}
|
|
className="manage-note-card"
|
|
style={{
|
|
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
|
|
padding: 'var(--space-4)',
|
|
borderRadius: 'var(--radius-lg)',
|
|
background: GLASS_INNER,
|
|
border: '1px solid var(--color-border)',
|
|
boxShadow: 'var(--shadow-sm)',
|
|
}}
|
|
>
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>
|
|
{note.content || '—'}
|
|
</p>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fmtDateMedium(note.created_at)}>
|
|
{fmtRelative(note.created_at)}
|
|
</span>
|
|
{note.created_by && (
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{note.created_by}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{canEdit && (
|
|
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
|
|
<button onClick={() => setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
|
|
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', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
|
|
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
|
|
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
|
|
>
|
|
<Icon name="edit" size={13} />
|
|
</button>
|
|
<button onClick={() => setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
|
|
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', 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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
</div>
|
|
|
|
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
|
|
|
<AddDeviceUserModal
|
|
open={showAddUser}
|
|
deviceId={id}
|
|
existingUserIds={existingUserIds}
|
|
onClose={() => setShowAddUser(false)}
|
|
onAdded={handleUserAdded}
|
|
/>
|
|
|
|
<DeviceNoteModal
|
|
open={noteModal.open}
|
|
deviceId={id}
|
|
note={noteModal.note}
|
|
onClose={() => setNoteModal({ open: false, note: null })}
|
|
onSaved={handleNoteSaved}
|
|
/>
|
|
|
|
{/* Issue form — pre-link to this device (locked — cannot be removed) */}
|
|
<EntryFormModal
|
|
open={issueModal.open}
|
|
entry={issueModal.entry}
|
|
defaultType="issue"
|
|
prefilledLinks={issueModal.entry
|
|
? undefined
|
|
: [{ entity_type: 'device', entity_id: id, display_name: device?.console_name || device?.device_name || sn, locked: true }]
|
|
}
|
|
knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined}
|
|
onClose={() => setIssueModal({ open: false, entry: null })}
|
|
onSaved={handleIssueSaved}
|
|
onDelete={canEdit ? (entryId) => {
|
|
setIssueModal({ open: false, entry: null })
|
|
setConfirmDeleteIssue(entryId)
|
|
} : null}
|
|
/>
|
|
|
|
{/* Confirm: remove user */}
|
|
<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}
|
|
/>
|
|
|
|
{/* Confirm: delete note */}
|
|
<ConfirmDialog
|
|
open={!!confirmDeleteNote}
|
|
variant="danger"
|
|
title="Delete Note"
|
|
message="This note will be permanently deleted."
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDeleteNote}
|
|
onClose={() => setConfirmDeleteNote(null)}
|
|
loading={deletingNote === confirmDeleteNote?.id}
|
|
/>
|
|
|
|
{/* Confirm: delete issue */}
|
|
<ConfirmDialog
|
|
open={!!confirmDeleteIssue}
|
|
variant="danger"
|
|
title="Delete Issue"
|
|
message="This issue and all its entity links will be permanently removed."
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDeleteIssue}
|
|
onClose={() => setConfirmDeleteIssue(null)}
|
|
loading={deletingIssue}
|
|
/>
|
|
</>
|
|
)
|
|
}
|