// frontend/src/pages/crm/comms/helpdesk/IssuesTab.jsx
// Global Issues list — all open/researching/resolved issues across all entities.
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import { useToast } from '@/components/ui/Toast'
import usePreferences from '@/hooks/usePreferences'
import Button from '@/components/ui/Button'
import Spinner from '@/components/ui/Spinner'
import SearchBar from '@/components/ui/SearchBar'
import Select from '@/components/ui/Select'
import Pagination from '@/components/ui/Pagination'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
// ─── Constants ────────────────────────────────────────────────────────────────
const STATUS_META = {
open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)', dot: 'var(--color-danger)' },
researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)', dot: 'var(--color-warning)' },
resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)', dot: 'var(--color-success)' },
}
const SEVERITY_META = {
low: { label: 'Low', color: 'var(--color-info)', bars: 1 },
medium: { label: 'Medium', color: 'var(--color-warning)', bars: 2 },
high: { label: 'High', color: 'var(--color-danger)', bars: 3 },
critical: { label: 'Critical', color: 'var(--color-danger)', bars: 4, breathe: true },
}
const CATEGORY_META = {
technical: { label: 'Technical Issue' },
install_support: { label: 'Install Support' },
general: { label: 'General' },
}
const ENTITY_LINKS = {
device: { label: 'Device', href: (id) => `/devices/${id}`, bg: 'rgba(186,230,253,0.10)', border: 'rgba(186,230,253,0.20)', color: 'rgb(186,230,253)' },
app_user: { label: 'User', href: (id) => `/users/${id}`, bg: 'rgba(147,197,253,0.10)', border: 'rgba(147,197,253,0.20)', color: 'rgb(147,197,253)' },
customer: { label: 'Customer', href: (id) => `/crm/customers/${id}`, bg: 'rgba(96,165,250,0.12)', border: 'rgba(96,165,250,0.25)', color: 'rgb(96,165,250)' },
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatusPill({ status }) {
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)', dot: 'var(--color-text-muted)' }
return (
{m.label}
)
}
function SeverityBars({ severity }) {
if (!severity) return —
const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)', bars: 1 }
return (
{/* 4-bar signal icon — filled bars match severity */}
{[1, 2, 3, 4].map(b => (
))}
{m.label}
)
}
const LINKED_TO_CAP = 3
function EntityChips({ links, nameMap }) {
const [overflowOpen, setOverflowOpen] = useState(false)
if (!links || links.length === 0) return —
const visible = links.slice(0, LINKED_TO_CAP)
const overflow = links.slice(LINKED_TO_CAP)
const renderChip = (l) => {
const meta = ENTITY_LINKS[l.entity_type]
if (!meta) return null
const name = nameMap?.[l.entity_id] || l.entity_id.slice(0, 8) + '…'
return (
e.stopPropagation()}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
backgroundColor: meta.bg,
border: `1px solid ${meta.border}`,
fontSize: 'var(--font-size-xs)',
color: meta.color,
fontWeight: 'var(--font-weight-medium)',
textDecoration: 'none',
whiteSpace: 'nowrap',
alignSelf: 'flex-start',
}}
>
{name}
)
}
return (
<>
{visible.map(renderChip)}
{overflow.length > 0 && (
)}
{/* Overflow mini-modal */}
{overflowOpen && (
e.stopPropagation()}
style={{
position: 'fixed', inset: 0, zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
backgroundColor: 'rgba(10,14,20,0.65)',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
}}
onMouseDown={e => { if (e.target === e.currentTarget) setOverflowOpen(false) }}
>
{links.length} linked entities
{links.map(renderChip)}
)}
>
)
}
// ─── IssuesTab ────────────────────────────────────────────────────────────────
const PAGE_SIZE_OPTIONS = [10, 25, 50]
// gridTemplateColumns shared by header and every row
// "Researching" pill + padding ≈ 126px → 130px. Longest chip ≈ 130px → 140px.
const GRID_COLS = '130px 1fr 140px 120px 180px 120px 110px'
export default function IssuesTab({ onOpenCreate }) {
const { hasPermission } = useAuth()
const { toast } = useToast()
const canEdit = hasPermission('crm', 'edit')
const canAdd = hasPermission('crm', 'add')
const canDelete = hasPermission('crm', 'delete')
const { prefs, setPref, setPrefBatch, ready } = usePreferences('helpdesk_issues')
const [issues, setIssues] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// entity ID → display name lookup for the Linked To column
const [nameMap, setNameMap] = useState({})
const [search, setSearch] = useState('')
const [status, setStatus] = useState('')
const [severity, setSeverity] = useState('')
const [category, setCategory] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(25)
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [formOpen, setFormOpen] = useState(false)
const [editEntry, setEditEntry] = useState(null)
const [deleteId, setDeleteId] = useState(null)
const [deleting, setDeleting] = useState(false)
// When parent increments openTrigger, open the create form
useEffect(() => {
if (onOpenCreate && onOpenCreate > 0) openCreate()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onOpenCreate])
// ── Load preferences once ready ──
useEffect(() => {
if (!ready || prefsLoaded) return
if (prefs.status !== undefined) setStatus(prefs.status)
if (prefs.severity !== undefined) setSeverity(prefs.severity)
if (prefs.category !== undefined) setCategory(prefs.category)
if (prefs.page_size !== undefined) setPageSize(prefs.page_size)
setPrefsLoaded(true)
}, [ready, prefs, prefsLoaded])
// ── Persist filter changes ──
useEffect(() => {
if (!prefsLoaded) return
setPrefBatch({ status, severity, category, page_size: pageSize })
}, [status, severity, category, pageSize]) // eslint-disable-line react-hooks/exhaustive-deps
// ── Fetch entity display names for all links in the current page ──
const resolveNames = useCallback(async (rows) => {
// Collect unique IDs by type
const byType = { device: new Set(), app_user: new Set(), customer: new Set() }
for (const issue of rows) {
for (const l of issue.links || []) {
if (byType[l.entity_type]) byType[l.entity_type].add(l.entity_id)
}
}
const map = {}
await Promise.all([
// Devices — fetch individually (no bulk ID endpoint)
...[...byType.device].map(async (id) => {
try {
const d = await api.get(`/devices/${id}`)
map[id] = d.device_name || id
} catch { map[id] = id }
}),
// App users
...[...byType.app_user].map(async (id) => {
try {
const d = await api.get(`/users/${id}`)
map[id] = d.display_name || d.email || id
} catch { map[id] = id }
}),
// Customers
...[...byType.customer].map(async (id) => {
try {
const d = await api.get(`/crm/customers/${id}`)
const name = [d.name, d.surname].filter(Boolean).join(' ') || d.organization
map[id] = name || id
} catch { map[id] = id }
}),
])
setNameMap(prev => ({ ...prev, ...map }))
}, [])
// ── Fetch ──
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const params = new URLSearchParams({ type: 'issue', page, limit: pageSize })
if (status) params.set('status', status)
if (severity) params.set('severity', severity)
if (category) params.set('category', category)
const data = await api.get(`/notes?${params}`)
const rows = data.data || []
setIssues(rows)
setTotal(data.pagination?.total ?? 0)
resolveNames(rows)
} catch (err) {
setError(err.message || 'Failed to load issues')
} finally {
setLoading(false)
}
}, [page, pageSize, status, severity, category, resolveNames])
useEffect(() => { load() }, [load])
useEffect(() => { setPage(1) }, [status, severity, category, search])
// ── Client-side search filter (title + author) ──
const q = search.trim().toLowerCase()
const displayed = q
? issues.filter(i =>
i.title.toLowerCase().includes(q) ||
(i.author_name || '').toLowerCase().includes(q) ||
(i.body || '').toLowerCase().includes(q)
)
: issues
// ── Delete ──
const handleDelete = useCallback(async () => {
if (!deleteId) return
setDeleting(true)
try {
await api.delete(`/notes/${deleteId}`)
toast.success('Deleted', 'Issue removed.')
setDeleteId(null)
load()
} catch (err) {
toast.danger('Delete failed', err.message)
} finally {
setDeleting(false)
}
}, [deleteId, toast, load])
const openCreate = () => { setEditEntry(null); setFormOpen(true) }
const openEdit = (e) => { setEditEntry(e); setFormOpen(true) }
const hasFilters = status || severity || category || search
return (
<>
{/* Toolbar — single row, SearchBar expands */}
{hasFilters && (
)}
{/* Error */}
{error && (
{error}
)}
{/* Table */}
{/* Table header */}
{['Status', 'Issue', 'Category', 'Severity', 'Linked To', 'Reported By', 'Date'].map(h => (
{h}
))}
{/* Body */}
{loading ? (
Loading issues…
) : displayed.length === 0 ? (
{hasFilters ? 'No issues match your filters.' : 'No issues recorded yet.'}
{canAdd && !hasFilters && (
)}
) : (
displayed.map((issue, idx) => (
canEdit && openEdit(issue)}
>
{/* Status */}
{/* Title + body preview */}
{issue.title}
{issue.body && (
{issue.body.replace(/<[^>]*>/g, '').slice(0, 100)}
)}
{/* Category */}
{CATEGORY_META[issue.category]?.label || '—'}
{/* Severity */}
{/* Linked entities */}
{/* Author */}
{issue.author_name || '—'}
{/* Date */}
{fmtRelative(issue.created_at)}
))
)}
{/* Footer: count + pagination */}
{!loading && displayed.length > 0 && (
{total} issue{total !== 1 ? 's' : ''}{hasFilters ? ' (filtered)' : ''}
{ setPageSize(s); setPage(1) }}
pageSizes={PAGE_SIZE_OPTIONS}
/>
)}
{/* Modals */}
{ setFormOpen(false); setEditEntry(null) }}
onSaved={() => { setFormOpen(false); setEditEntry(null); load() }}
onDelete={canDelete ? (id) => { setFormOpen(false); setDeleteId(id) } : null}
/>
setDeleteId(null)}
loading={deleting}
/>
>
)
}