609 lines
24 KiB
React
609 lines
24 KiB
React
// 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 (
|
|
<span style={{
|
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
padding: '3px 10px',
|
|
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',
|
|
}}>
|
|
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: m.dot, flexShrink: 0 }} />
|
|
{m.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SeverityBars({ severity }) {
|
|
if (!severity) return <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>—</span>
|
|
const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)', bars: 1 }
|
|
return (
|
|
<span
|
|
className={m.breathe ? 'severity-critical' : undefined}
|
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}
|
|
>
|
|
{/* 4-bar signal icon — filled bars match severity */}
|
|
<span style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 14 }}>
|
|
{[1, 2, 3, 4].map(b => (
|
|
<span key={b} style={{
|
|
width: 3,
|
|
height: 3 + b * 2.5,
|
|
borderRadius: 1,
|
|
backgroundColor: b <= m.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>
|
|
)
|
|
}
|
|
|
|
const LINKED_TO_CAP = 3
|
|
|
|
function EntityChips({ links, nameMap }) {
|
|
const [overflowOpen, setOverflowOpen] = useState(false)
|
|
|
|
if (!links || links.length === 0) return <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>—</span>
|
|
|
|
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 (
|
|
<Link
|
|
key={l.id}
|
|
to={meta.href(l.entity_id)}
|
|
onClick={e => 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}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
{visible.map(renderChip)}
|
|
{overflow.length > 0 && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); setOverflowOpen(true) }}
|
|
style={{
|
|
display: 'inline-flex', alignItems: 'center',
|
|
padding: '2px 8px',
|
|
borderRadius: 'var(--radius-sm)',
|
|
backgroundColor: 'var(--color-bg-island)',
|
|
border: '1px solid var(--color-border-strong)',
|
|
fontSize: 'var(--font-size-xs)',
|
|
color: 'var(--color-text-secondary)',
|
|
fontWeight: 'var(--font-weight-medium)',
|
|
cursor: 'pointer',
|
|
whiteSpace: 'nowrap',
|
|
alignSelf: 'flex-start',
|
|
fontFamily: 'var(--font-family-base)',
|
|
transition: 'background 0.1s, color 0.1s',
|
|
}}
|
|
onMouseEnter={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'; e.currentTarget.style.color = 'var(--color-text-primary)' }}
|
|
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'var(--color-bg-island)'; e.currentTarget.style.color = 'var(--color-text-secondary)' }}
|
|
>
|
|
+{overflow.length + 0} more
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overflow mini-modal */}
|
|
{overflowOpen && (
|
|
<div
|
|
onClick={e => 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) }}
|
|
>
|
|
<div style={{
|
|
minWidth: 260, maxWidth: 400,
|
|
backgroundColor: 'var(--color-bg-card)',
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-xl)',
|
|
boxShadow: 'var(--shadow-modal)',
|
|
overflow: 'hidden',
|
|
}}>
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: 'var(--space-4) var(--space-5)',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
backgroundColor: 'rgba(28,32,38,0.50)',
|
|
}}>
|
|
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
|
|
{links.length} linked entities
|
|
</span>
|
|
<button
|
|
onClick={() => setOverflowOpen(false)}
|
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--color-text-muted)', lineHeight: 1, padding: 4 }}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div style={{ padding: 'var(--space-4) var(--space-5)', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
{links.map(renderChip)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ─── 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 (
|
|
<>
|
|
<style>{`
|
|
.issue-row { transition: background 0.12s; }
|
|
.issue-row:hover { background: rgba(49,53,60,0.50) !important; }
|
|
|
|
@keyframes severity-glow {
|
|
0%, 100% { filter: drop-shadow(0 0 0px var(--color-danger)); }
|
|
50% { filter: drop-shadow(0 0 6px var(--color-danger)); }
|
|
}
|
|
.severity-critical {
|
|
animation: severity-glow 0.9s ease-in-out infinite;
|
|
}
|
|
|
|
.issues-toolbar .btn {
|
|
padding-top: var(--space-3) !important;
|
|
padding-bottom: var(--space-3) !important;
|
|
line-height: var(--line-height-base) !important;
|
|
font-size: var(--font-size-sm) !important;
|
|
}
|
|
`}</style>
|
|
|
|
{/* Toolbar — single row, SearchBar expands */}
|
|
<div className="issues-toolbar" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'nowrap' }}>
|
|
<SearchBar
|
|
value={search}
|
|
onChange={setSearch}
|
|
placeholder="Search issues…"
|
|
style={{ flex: 1, minWidth: 0 }}
|
|
/>
|
|
|
|
<div style={{ flexShrink: 0, minWidth: 136 }}>
|
|
<Select value={status} onChange={e => setStatus(e.target.value)} placeholder="All statuses">
|
|
<option value="">All statuses</option>
|
|
{Object.entries(STATUS_META).map(([k, v]) => (
|
|
<option key={k} value={k}>{v.label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div style={{ flexShrink: 0, minWidth: 136 }}>
|
|
<Select value={severity} onChange={e => setSeverity(e.target.value)} placeholder="All severities">
|
|
<option value="">All severities</option>
|
|
{Object.entries(SEVERITY_META).map(([k, v]) => (
|
|
<option key={k} value={k}>{v.label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
<div style={{ flexShrink: 0, minWidth: 152 }}>
|
|
<Select value={category} onChange={e => setCategory(e.target.value)} placeholder="All categories">
|
|
<option value="">All categories</option>
|
|
{Object.entries(CATEGORY_META).map(([k, v]) => (
|
|
<option key={k} value={k}>{v.label}</option>
|
|
))}
|
|
</Select>
|
|
</div>
|
|
|
|
{hasFilters && (
|
|
<Button variant="ghost" size="sm" onClick={() => { setStatus(''); setSeverity(''); setCategory(''); setSearch('') }}>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div style={{
|
|
padding: 'var(--space-3) var(--space-4)',
|
|
backgroundColor: 'var(--color-danger-bg)',
|
|
border: '1px solid var(--color-danger)',
|
|
borderRadius: 'var(--radius-lg)',
|
|
color: 'var(--color-danger)',
|
|
fontSize: 'var(--font-size-sm)',
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<div style={{
|
|
borderRadius: 'var(--radius-xl)',
|
|
border: '1px solid var(--color-border)',
|
|
overflow: 'hidden',
|
|
backgroundColor: 'rgba(28,32,38,0.30)',
|
|
backdropFilter: 'var(--blur-modal)',
|
|
WebkitBackdropFilter: 'var(--blur-modal)',
|
|
boxShadow: 'var(--shadow-card)',
|
|
}}>
|
|
{/* Table header */}
|
|
<div style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: GRID_COLS,
|
|
padding: '0 var(--space-5)',
|
|
borderBottom: '1px solid var(--color-border)',
|
|
backgroundColor: 'rgba(28,32,38,0.40)',
|
|
backdropFilter: 'var(--blur-modal)',
|
|
WebkitBackdropFilter: 'var(--blur-modal)',
|
|
}}>
|
|
{['Status', 'Issue', 'Category', 'Severity', 'Linked To', 'Reported By', 'Date'].map(h => (
|
|
<div key={h} style={{
|
|
padding: 'var(--space-3) 0',
|
|
paddingRight: h === 'Linked To' ? 'var(--space-4)' : 0,
|
|
fontSize: 'var(--font-size-xs)',
|
|
fontWeight: 'var(--font-weight-semibold)',
|
|
color: 'var(--color-text-muted)',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: 'var(--tracking-wide)',
|
|
}}>
|
|
{h}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Body */}
|
|
{loading ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 'var(--space-3)', padding: 'var(--space-12)' }}>
|
|
<Spinner size="md" />
|
|
<span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>Loading issues…</span>
|
|
</div>
|
|
) : displayed.length === 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)', padding: 'var(--space-12)', color: 'var(--color-text-muted)' }}>
|
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.4 }}>
|
|
<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>
|
|
<p style={{ margin: 0, fontSize: 'var(--font-size-base)' }}>
|
|
{hasFilters ? 'No issues match your filters.' : 'No issues recorded yet.'}
|
|
</p>
|
|
{canAdd && !hasFilters && (
|
|
<Button variant="secondary" size="sm" onClick={openCreate}>Record first issue</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
displayed.map((issue, idx) => (
|
|
<div
|
|
key={issue.id}
|
|
className="issue-row"
|
|
style={{
|
|
display: 'grid',
|
|
gridTemplateColumns: GRID_COLS,
|
|
padding: '0 var(--space-5)',
|
|
borderBottom: idx < displayed.length - 1 ? '1px solid var(--color-border)' : 'none',
|
|
cursor: canEdit ? 'pointer' : 'default',
|
|
alignItems: 'center',
|
|
}}
|
|
onClick={() => canEdit && openEdit(issue)}
|
|
>
|
|
{/* Status */}
|
|
<div style={{ padding: 'var(--space-4) 0' }}>
|
|
<StatusPill status={issue.status} />
|
|
</div>
|
|
|
|
{/* Title + body preview */}
|
|
<div style={{ padding: 'var(--space-4) var(--space-3) var(--space-4) 0', minWidth: 0 }}>
|
|
<p style={{ margin: 0, fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', fontSize: 'var(--font-size-base)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{issue.title}
|
|
</p>
|
|
{issue.body && (
|
|
<p style={{ margin: '2px 0 0', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{issue.body.replace(/<[^>]*>/g, '').slice(0, 100)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Category */}
|
|
<div style={{ padding: 'var(--space-4) 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)' }}>
|
|
{CATEGORY_META[issue.category]?.label || '—'}
|
|
</div>
|
|
|
|
{/* Severity */}
|
|
<div style={{ padding: 'var(--space-4) 0' }}>
|
|
<SeverityBars severity={issue.severity} />
|
|
</div>
|
|
|
|
{/* Linked entities */}
|
|
<div style={{ padding: 'var(--space-4) var(--space-4) var(--space-4) 0' }}>
|
|
<EntityChips links={issue.links} nameMap={nameMap} />
|
|
</div>
|
|
|
|
{/* Author */}
|
|
<div style={{ padding: 'var(--space-4) 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{issue.author_name || '—'}
|
|
</div>
|
|
|
|
{/* Date */}
|
|
<div style={{ padding: 'var(--space-4) 0' }} title={fmtDateMedium(issue.created_at)}>
|
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
|
{fmtRelative(issue.created_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
|
|
{/* Footer: count + pagination */}
|
|
{!loading && displayed.length > 0 && (
|
|
<div style={{
|
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
padding: 'var(--space-3) var(--space-5)',
|
|
borderTop: '1px solid var(--color-border)',
|
|
backgroundColor: 'rgba(28,32,38,0.40)',
|
|
backdropFilter: 'var(--blur-modal)',
|
|
WebkitBackdropFilter: 'var(--blur-modal)',
|
|
}}>
|
|
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
|
{total} issue{total !== 1 ? 's' : ''}{hasFilters ? ' (filtered)' : ''}
|
|
</span>
|
|
<Pagination
|
|
page={page}
|
|
pageSize={pageSize}
|
|
total={total}
|
|
onPageChange={setPage}
|
|
onSizeChange={s => { setPageSize(s); setPage(1) }}
|
|
pageSizes={PAGE_SIZE_OPTIONS}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Modals */}
|
|
<EntryFormModal
|
|
open={formOpen}
|
|
entry={editEntry}
|
|
defaultType="issue"
|
|
onClose={() => { setFormOpen(false); setEditEntry(null) }}
|
|
onSaved={() => { setFormOpen(false); setEditEntry(null); load() }}
|
|
onDelete={canDelete ? (id) => { setFormOpen(false); setDeleteId(id) } : null}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={!!deleteId}
|
|
variant="danger"
|
|
title="Delete issue?"
|
|
message="This issue and all its entity links will be permanently removed."
|
|
confirmLabel="Delete"
|
|
onConfirm={handleDelete}
|
|
onClose={() => setDeleteId(null)}
|
|
loading={deleting}
|
|
/>
|
|
</>
|
|
)
|
|
}
|