Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
// 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user