// 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} /> ) }