This commit is contained in:
fahed
2026-02-23 11:57:32 +03:00
parent 4522edeea8
commit 8436c49142
50 changed files with 6447 additions and 55 deletions

473
client/src/pages/Issues.jsx Normal file
View File

@@ -0,0 +1,473 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { AlertCircle, Search, LayoutGrid, List, ChevronUp, ChevronDown } from 'lucide-react'
import { AppContext } from '../App'
import { api } from '../utils/api'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import IssueDetailPanel from '../components/IssueDetailPanel'
import IssueCard from '../components/IssueCard'
import EmptyState from '../components/EmptyState'
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
const TYPE_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'correction', label: 'Correction' },
{ value: 'complaint', label: 'Complaint' },
{ value: 'suggestion', label: 'Suggestion' },
{ value: 'other', label: 'Other' },
]
const PRIORITY_CONFIG = {
low: { label: 'Low', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
medium: { label: 'Medium', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
high: { label: 'High', bg: 'bg-orange-100', text: 'text-orange-700', dot: 'bg-orange-500' },
urgent: { label: 'Urgent', bg: 'bg-red-100', text: 'text-red-700', dot: 'bg-red-500' },
}
const STATUS_CONFIG = {
new: { label: 'New', bg: 'bg-purple-100', text: 'text-purple-700', dot: 'bg-purple-500' },
acknowledged: { label: 'Acknowledged', bg: 'bg-blue-100', text: 'text-blue-700', dot: 'bg-blue-500' },
in_progress: { label: 'In Progress', bg: 'bg-amber-100', text: 'text-amber-700', dot: 'bg-amber-500' },
resolved: { label: 'Resolved', bg: 'bg-emerald-100', text: 'text-emerald-700', dot: 'bg-emerald-500' },
declined: { label: 'Declined', bg: 'bg-surface-tertiary', text: 'text-text-secondary', dot: 'bg-text-tertiary' },
}
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
export default function Issues() {
const { t } = useLanguage()
const toast = useToast()
const { brands } = useContext(AppContext)
const [issues, setIssues] = useState([])
const [counts, setCounts] = useState({})
const [loading, setLoading] = useState(true)
const [selectedIssue, setSelectedIssue] = useState(null)
const [searchTerm, setSearchTerm] = useState('')
const [filters, setFilters] = useState({ status: '', category: '', type: '', priority: '', brand: '' })
const [categories, setCategories] = useState([])
const [teamMembers, setTeamMembers] = useState([])
// View mode
const [viewMode, setViewMode] = useState('board')
// Drag and drop
const [draggedIssue, setDraggedIssue] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
// List sorting
const [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState('desc')
useEffect(() => { loadData() }, [])
const loadData = async () => {
try {
setLoading(true)
const [issuesData, categoriesData, teamData] = await Promise.all([
api.get('/issues'),
api.get('/issues/categories'),
api.get('/users/team'),
])
setIssues(issuesData.issues || [])
setCounts(issuesData.counts || {})
setCategories(categoriesData || [])
setTeamMembers(Array.isArray(teamData) ? teamData : teamData.data || [])
} catch (err) {
console.error('Failed to load issues:', err)
} finally {
setLoading(false)
}
}
// Filtering
const filteredIssues = useMemo(() => {
let filtered = [...issues]
if (searchTerm) {
const term = searchTerm.toLowerCase()
filtered = filtered.filter(i =>
i.title?.toLowerCase().includes(term) ||
i.submitter_name?.toLowerCase().includes(term) ||
i.submitter_email?.toLowerCase().includes(term) ||
i.category?.toLowerCase().includes(term)
)
}
if (filters.status) filtered = filtered.filter(i => i.status === filters.status)
if (filters.category) filtered = filtered.filter(i => i.category === filters.category)
if (filters.type) filtered = filtered.filter(i => i.type === filters.type)
if (filters.priority) filtered = filtered.filter(i => i.priority === filters.priority)
if (filters.brand) filtered = filtered.filter(i => String(i.brand_id) === String(filters.brand))
return filtered
}, [issues, searchTerm, filters])
// List sorting
const sortedIssues = useMemo(() => {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
const statusOrder = { new: 0, acknowledged: 1, in_progress: 2, resolved: 3, declined: 4 }
return [...filteredIssues].sort((a, b) => {
let cmp = 0
if (sortBy === 'created_at') {
cmp = (a.created_at || a.CreatedAt || '').localeCompare(b.created_at || b.CreatedAt || '')
} else if (sortBy === 'title') {
cmp = (a.title || '').localeCompare(b.title || '')
} else if (sortBy === 'priority') {
cmp = (priorityOrder[a.priority] ?? 2) - (priorityOrder[b.priority] ?? 2)
} else if (sortBy === 'status') {
cmp = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0)
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filteredIssues, sortBy, sortDir])
const updateFilter = (key, value) => setFilters(f => ({ ...f, [key]: value }))
const clearFilters = () => {
setFilters({ status: '', category: '', type: '', priority: '', brand: '' })
setSearchTerm('')
}
const hasActiveFilters = Object.values(filters).some(Boolean) || searchTerm
const getAssigneeName = (assignedToId) => {
if (!assignedToId) return '—'
const member = teamMembers.find(m => m.id === assignedToId || m._id === assignedToId)
return member?.name || '—'
}
const formatDate = (dateStr) => {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
// Drag and drop handlers
const handleMoveIssue = async (issueId, newStatus) => {
try {
await api.patch(`/issues/${issueId}`, { status: newStatus })
toast.success(t('issues.statusUpdated'))
loadData()
} catch (err) {
console.error('Move issue failed:', err)
toast.error('Failed to update status')
}
}
const handleDragStart = (e, issue) => {
setDraggedIssue(issue)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedIssue(null)
setDragOverCol(null)
}
const handleDragOver = (e, colStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
setDragOverCol(colStatus)
}
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
}
const handleDrop = (e, colStatus) => {
e.preventDefault()
setDragOverCol(null)
if (draggedIssue && draggedIssue.status !== colStatus) {
handleMoveIssue(draggedIssue.Id || draggedIssue.id, colStatus)
}
setDraggedIssue(null)
}
const toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(col); setSortDir('asc') }
}
const SortIcon = ({ col }) => {
if (sortBy !== col) return null
return sortDir === 'asc'
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
}
if (loading) {
return (
<div className="space-y-4">
{viewMode === 'board' ? <SkeletonKanbanBoard /> : <SkeletonTable rows={8} cols={5} />}
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary flex items-center gap-2">
<AlertCircle className="w-7 h-7" />
Issues
</h1>
<p className="text-text-secondary mt-1">Track and manage issue submissions</p>
</div>
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
{[
{ mode: 'board', icon: LayoutGrid, label: t('issues.board') },
{ mode: 'list', icon: List, label: t('issues.list') },
].map(({ mode, icon: Icon, label }) => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
viewMode === mode
? 'bg-white text-text-primary shadow-sm'
: 'text-text-tertiary hover:text-text-secondary'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
{/* Status Counts */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 stagger-children">
{Object.entries(STATUS_CONFIG).map(([status, config]) => (
<div
key={status}
className={`bg-surface rounded-lg border p-4 cursor-pointer hover:shadow-sm transition-all ${
filters.status === status ? 'border-brand-primary ring-1 ring-brand-primary/20' : 'border-border'
}`}
onClick={() => updateFilter('status', filters.status === status ? '' : status)}
>
<div className="flex items-center gap-2 mb-1">
<span className={`w-2 h-2 rounded-full ${config.dot}`}></span>
<span className="text-xs font-medium text-text-tertiary uppercase">{config.label}</span>
</div>
<div className="text-2xl font-bold text-text-primary">{counts[status] || 0}</div>
</div>
))}
</div>
{/* Search & Filters - always visible inline */}
<div className="flex items-center gap-3 flex-wrap">
{/* Search */}
<div className="relative flex-1 min-w-[200px] max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
<input
type="text"
placeholder="Search issues..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
/>
</div>
<select
value={filters.status}
onChange={e => updateFilter('status', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Statuses</option>
{Object.entries(STATUS_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</select>
<select
value={filters.category}
onChange={e => updateFilter('category', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Categories</option>
{categories.map(cat => <option key={cat} value={cat}>{cat}</option>)}
</select>
<select
value={filters.type}
onChange={e => updateFilter('type', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Types</option>
{TYPE_OPTIONS.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
</select>
<select
value={filters.brand || ''}
onChange={e => updateFilter('brand', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Brands</option>
{(brands || []).map(b => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
<select
value={filters.priority}
onChange={e => updateFilter('priority', e.target.value)}
className="px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 bg-surface"
>
<option value="">All Priorities</option>
{Object.entries(PRIORITY_CONFIG).map(([key, config]) => (
<option key={key} value={key}>{config.label}</option>
))}
</select>
{hasActiveFilters && (
<button onClick={clearFilters} className="px-3 py-2 rounded-lg text-sm font-medium text-text-tertiary hover:text-text-primary">
Clear All
</button>
)}
</div>
{/* Board View */}
{viewMode === 'board' && (
filteredIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
/>
) : (
<div className="flex gap-4 overflow-x-auto pb-4">
{STATUS_ORDER.map(status => {
const config = STATUS_CONFIG[status]
const columnIssues = filteredIssues.filter(i => i.status === status)
return (
<div
key={status}
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
}`}
onDragOver={e => handleDragOver(e, status)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, status)}
>
{/* Column header */}
<div className="px-3 py-3 border-b border-border">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
<span className="text-sm font-semibold text-text-primary">{config.label}</span>
<span className="text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded-full font-medium">
{columnIssues.length}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[120px]">
{columnIssues.length === 0 ? (
<div className="text-center py-6 text-xs text-text-tertiary">
{t('issues.noIssuesInColumn')}
</div>
) : (
columnIssues.map(issue => (
<div
key={issue.Id || issue.id}
draggable
onDragStart={e => handleDragStart(e, issue)}
onDragEnd={handleDragEnd}
>
<IssueCard issue={issue} onClick={setSelectedIssue} />
</div>
))
)}
</div>
</div>
)
})}
</div>
)
)}
{/* List View */}
{viewMode === 'list' && (
sortedIssues.length === 0 ? (
<EmptyState
icon={AlertCircle}
title="No issues found"
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
/>
) : (
<div className="bg-surface rounded-lg border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-surface-secondary border-b border-border">
<tr>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('title')}>
Title <SortIcon col="title" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Submitter</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Brand</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Category</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('priority')}>
Priority <SortIcon col="priority" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('status')}>
Status <SortIcon col="status" />
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">Assigned To</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer hover:text-text-primary" onClick={() => toggleSort('created_at')}>
Created <SortIcon col="created_at" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{sortedIssues.map(issue => {
const priorityConfig = PRIORITY_CONFIG[issue.priority] || PRIORITY_CONFIG.medium
const statusConfig = STATUS_CONFIG[issue.status] || STATUS_CONFIG.new
return (
<tr
key={issue.Id || issue.id}
onClick={() => setSelectedIssue(issue)}
className="hover:bg-surface-secondary cursor-pointer transition-colors"
>
<td className="px-4 py-3 text-sm font-medium text-text-primary">{issue.title}</td>
<td className="px-4 py-3 text-sm text-text-secondary">
<div>{issue.submitter_name}</div>
<div className="text-xs text-text-tertiary">{issue.submitter_email}</div>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{issue.brand_name || issue.brandName || '—'}</td>
<td className="px-4 py-3 text-sm text-text-secondary">{issue.category || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className="text-xs px-2 py-1 rounded-full bg-surface-tertiary text-text-secondary">
{TYPE_OPTIONS.find(t => t.value === issue.type)?.label || issue.type}
</span>
</td>
<td className="px-4 py-3 text-sm">
<span className={`text-xs px-2 py-1 rounded-full font-medium ${priorityConfig.bg} ${priorityConfig.text}`}>
{priorityConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm">
<span className={`text-xs px-2 py-1 rounded-full font-medium flex items-center gap-1.5 w-fit ${statusConfig.bg} ${statusConfig.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusConfig.dot}`}></span>
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-secondary">{getAssigneeName(issue.assigned_to_id)}</td>
<td className="px-4 py-3 text-sm text-text-tertiary">{formatDate(issue.created_at)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
)}
{/* Detail Panel */}
{selectedIssue && (
<IssueDetailPanel
issue={selectedIssue}
onClose={() => setSelectedIssue(null)}
onUpdate={loadData}
teamMembers={teamMembers}
/>
)}
</div>
)
}