Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
@@ -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}
/>
</>
)
}