Files
marketing-app/client/src/pages/Tasks.jsx
fahed 4522edeea8 feat: slide panels, task calendar, team management, project editing, collapsible sections
- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel
- Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView
- Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects
- Update Settings, Tasks, Team pages
- Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components
- Update server API utilities
- Remove tracked server/node_modules (now properly gitignored)
2026-02-19 11:35:42 +03:00

715 lines
32 KiB
JavaScript

import { useState, useEffect, useContext, useMemo } from 'react'
import { Plus, CheckSquare, Trash2, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
import TaskCard from '../components/TaskCard'
import TaskDetailPanel from '../components/TaskDetailPanel'
import TaskCalendarView from '../components/TaskCalendarView'
import DatePresetPicker from '../components/DatePresetPicker'
import EmptyState from '../components/EmptyState'
import { useToast } from '../components/ToastContainer'
import { format } from 'date-fns'
const VIEW_MODES = ['board', 'list', 'calendar']
const VIEW_ICONS = { board: LayoutGrid, list: List, calendar: CalendarDays }
export default function Tasks() {
const { t } = useLanguage()
const { currentUser, brands } = useContext(AppContext)
const { user: authUser, canEditResource, canDeleteResource } = useAuth()
const toast = useToast()
// Data
const [tasks, setTasks] = useState([])
const [projects, setProjects] = useState([])
const [loading, setLoading] = useState(true)
// UI state
const [viewMode, setViewMode] = useState('board')
const [selectedTask, setSelectedTask] = useState(null)
const [draggedTask, setDraggedTask] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
// Filters
const [searchQuery, setSearchQuery] = useState('')
const [filterProject, setFilterProject] = useState('')
const [filterBrand, setFilterBrand] = useState('')
const [filterStatus, setFilterStatus] = useState([]) // empty = all
const [filterPriority, setFilterPriority] = useState('')
const [filterAssignee, setFilterAssignee] = useState('')
const [filterCreator, setFilterCreator] = useState('')
const [filterDateFrom, setFilterDateFrom] = useState('')
const [filterDateTo, setFilterDateTo] = useState('')
const [filterOverdue, setFilterOverdue] = useState(false)
const [activePreset, setActivePreset] = useState('')
const [showFilters, setShowFilters] = useState(false)
// Assignable users & team
const [assignableUsers, setAssignableUsers] = useState([])
const [users, setUsers] = useState([])
const isSuperadmin = authUser?.role === 'superadmin'
useEffect(() => { loadTasks() }, [currentUser])
useEffect(() => {
api.get('/users/assignable').then(res => setAssignableUsers(res.data || res || [])).catch(() => {})
api.get('/projects').then(res => setProjects(res.data || res || [])).catch(() => {})
if (isSuperadmin) {
api.get('/team').then(res => setUsers(res.data || res || [])).catch(() => {})
}
}, [isSuperadmin])
const loadTasks = async () => {
try {
const res = await api.get('/tasks')
setTasks(res.data || res || [])
} catch (err) {
console.error('Failed to load tasks:', err)
} finally {
setLoading(false)
}
}
// Determine if any filter is active
const hasActiveFilters = searchQuery || filterProject || filterBrand || filterStatus.length > 0 || filterPriority || filterAssignee || filterCreator || filterDateFrom || filterDateTo || filterOverdue
const clearFilters = () => {
setSearchQuery('')
setFilterProject('')
setFilterBrand('')
setFilterStatus([])
setFilterPriority('')
setFilterAssignee('')
setFilterCreator('')
setFilterDateFrom('')
setFilterDateTo('')
setFilterOverdue(false)
}
// Client-side filtering
const filteredTasks = useMemo(() => {
return tasks.filter(task => {
// Search
if (searchQuery) {
const q = searchQuery.toLowerCase()
if (!(task.title || '').toLowerCase().includes(q) && !(task.description || '').toLowerCase().includes(q)) return false
}
// Project
if (filterProject && String(task.project_id || task.projectId || '') !== String(filterProject)) return false
// Brand
if (filterBrand && String(task.brand_id || task.brandId || '') !== String(filterBrand)) return false
// Status
if (filterStatus.length > 0 && !filterStatus.includes(task.status)) return false
// Priority
if (filterPriority && task.priority !== filterPriority) return false
// Assignee
if (filterAssignee) {
const assignee = task.assigned_to || task.assignedTo || task.assigned_to_id || task.assignedToId
if (String(assignee || '') !== String(filterAssignee)) return false
}
// Creator
if (filterCreator) {
const creator = task.created_by_user_id || task.createdByUserId
if (String(creator || '') !== String(filterCreator)) return false
}
// Date range
if (filterDateFrom) {
const dd = task.due_date || task.dueDate
if (!dd || new Date(dd) < new Date(filterDateFrom)) return false
}
if (filterDateTo) {
const dd = task.due_date || task.dueDate
if (!dd || new Date(dd) > new Date(filterDateTo + 'T23:59:59')) return false
}
// Overdue
if (filterOverdue) {
const dd = task.due_date || task.dueDate
if (!dd || new Date(dd) >= new Date() || task.status === 'done') return false
}
return true
})
}, [tasks, searchQuery, filterProject, filterBrand, filterStatus, filterPriority, filterAssignee, filterCreator, filterDateFrom, filterDateTo, filterOverdue])
// ─── CRUD ──────────────────────────────────────────
const handlePanelSave = async (taskId, data, files = []) => {
try {
if (taskId) {
// Edit mode
await api.patch(`/tasks/${taskId}`, data)
toast.success(t('tasks.updated'))
loadTasks()
const updated = { ...selectedTask, ...data }
setSelectedTask(updated)
} else {
// Create mode — create task then upload any pending files
const newTask = await api.post('/tasks', { ...data, is_personal: false })
const newId = newTask.Id || newTask.id || newTask._id
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
await api.upload(`/tasks/${newId}/attachments`, fd)
}
toast.success(t('tasks.created'))
setSelectedTask(null)
loadTasks()
}
} catch (err) {
console.error('Save failed:', err)
if (err.message?.includes('403')) {
toast.error(t('tasks.canOnlyEditOwn'))
} else {
toast.error(t('common.saveFailed'))
}
}
}
const handlePanelDelete = async (taskId) => {
try {
await api.delete(`/tasks/${taskId}`)
toast.success(t('tasks.deleted'))
setSelectedTask(null)
loadTasks()
} catch (err) {
console.error('Delete failed:', err)
toast.error(t('common.deleteFailed'))
}
}
const handleMove = async (taskId, newStatus) => {
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
toast.success(t('tasks.statusUpdated'))
loadTasks()
} catch (err) {
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
else toast.error(t('common.updateFailed'))
}
}
const openTask = (task) => {
setSelectedTask(task)
}
// ─── Drag and drop (Kanban) ─────────────────────────
const handleDragStart = (e, task) => {
setDraggedTask(task)
e.dataTransfer.effectAllowed = 'move'
setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
}
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedTask(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 (draggedTask && draggedTask.status !== colStatus) {
handleMove(draggedTask._id || draggedTask.id, colStatus)
}
setDraggedTask(null)
}
// ─── Kanban columns ──────────────────────────────────
const todoTasks = filteredTasks.filter(t => t.status === 'todo')
const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
const doneTasks = filteredTasks.filter(t => t.status === 'done')
const columns = [
{ label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
{ label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
{ label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
]
// ─── List view sorting ────────────────────────────────
const [sortBy, setSortBy] = useState('due_date')
const [sortDir, setSortDir] = useState('asc')
const sortedListTasks = useMemo(() => {
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 }
const statusOrder = { todo: 0, in_progress: 1, done: 2 }
return [...filteredTasks].sort((a, b) => {
let cmp = 0
if (sortBy === 'due_date') {
const da = a.due_date || a.dueDate || ''
const db = b.due_date || b.dueDate || ''
if (!da && !db) cmp = 0
else if (!da) cmp = 1
else if (!db) cmp = -1
else cmp = da.localeCompare(db)
} 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)
} else if (sortBy === 'title') {
cmp = (a.title || '').localeCompare(b.title || '')
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filteredTasks, sortBy, sortDir])
const toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(col); setSortDir('asc') }
}
// ─── Unique brands from tasks ─────────────────────────
const taskBrands = useMemo(() => {
const map = new Map()
for (const t of tasks) {
const bid = t.brand_id || t.brandId
const bname = t.brand_name || t.brandName
if (bid && bname) map.set(String(bid), bname)
}
return Array.from(map, ([id, name]) => ({ id, name }))
}, [tasks])
// ─── Unique projects from tasks ───────────────────────
const taskProjects = useMemo(() => {
const map = new Map()
for (const t of tasks) {
const pid = t.project_id || t.projectId
const pname = t.project_name || t.projectName
if (pid && pname) map.set(String(pid), pname)
}
return Array.from(map, ([id, name]) => ({ id, name }))
}, [tasks])
if (loading) {
return (
<div className="animate-pulse">
<div className="h-12 bg-surface-tertiary rounded-xl mb-4"></div>
<div className="grid grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => <div key={i} className="h-64 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
return (
<div className="space-y-4 animate-fade-in">
{/* ─── Toolbar ──────────────────────────────── */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Search */}
<div className="relative flex-1 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"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('tasks.search')}
className="w-full pl-9 pr-3 py-1.5 text-sm border border-border rounded-lg bg-surface-primary text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* View switcher */}
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
{VIEW_MODES.map(mode => {
const Icon = VIEW_ICONS[mode]
return (
<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" />
{t(`tasks.${mode}`)}
</button>
)
})}
</div>
{/* Filter toggle */}
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
showFilters || hasActiveFilters
? 'border-brand-primary/30 bg-brand-primary/5 text-brand-primary'
: 'border-border text-text-tertiary hover:text-text-secondary hover:border-border-dark'
}`}
>
<SlidersHorizontal className="w-3.5 h-3.5" />
{t('tasks.filters')}
{hasActiveFilters && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" />
)}
</button>
{/* Task count */}
<span className="text-xs text-text-tertiary whitespace-nowrap">
{filteredTasks.length}{hasActiveFilters ? ` ${t('tasks.of')} ${tasks.length}` : ''} {filteredTasks.length !== 1 ? t('tasks.tasks') : t('tasks.task')}
</span>
</div>
<button
onClick={() => { setSelectedTask({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '', project_id: '' }) }}
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm shrink-0"
>
<Plus className="w-4 h-4" />
{t('tasks.newTask')}
</button>
</div>
{/* ─── Filter Bar ───────────────────────────── */}
{showFilters && (
<div className="flex items-center gap-2 flex-wrap bg-surface-secondary rounded-xl px-4 py-3 border border-border-light">
{/* Project */}
<select
value={filterProject}
onChange={e => setFilterProject(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allProjects')}</option>
{taskProjects.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
{/* Brand */}
<select
value={filterBrand}
onChange={e => setFilterBrand(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allBrands')}</option>
{taskBrands.map(b => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</select>
{/* Status chips */}
<div className="flex items-center gap-1">
{['todo', 'in_progress', 'done'].map(s => {
const active = filterStatus.length === 0 || filterStatus.includes(s)
return (
<button
key={s}
onClick={() => {
if (filterStatus.length === 0) {
// Currently all shown — click means show only this one
setFilterStatus([s])
} else if (filterStatus.includes(s)) {
const next = filterStatus.filter(x => x !== s)
setFilterStatus(next.length === 0 ? [] : next)
} else {
setFilterStatus([...filterStatus, s])
}
}}
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
active
? 'bg-brand-primary/10 border-brand-primary/20 text-brand-primary'
: 'bg-white border-border text-text-tertiary'
}`}
>
{t(`tasks.${s}`)}
</button>
)
})}
</div>
{/* Priority */}
<select
value={filterPriority}
onChange={e => setFilterPriority(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allPriorities')}</option>
<option value="low">{t('tasks.priority.low')}</option>
<option value="medium">{t('tasks.priority.medium')}</option>
<option value="high">{t('tasks.priority.high')}</option>
<option value="urgent">{t('tasks.priority.urgent')}</option>
</select>
{/* Assignee */}
<select
value={filterAssignee}
onChange={e => setFilterAssignee(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allAssignees')}</option>
{(assignableUsers || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
{/* Creator (superadmin only) */}
{isSuperadmin && (
<select
value={filterCreator}
onChange={e => setFilterCreator(e.target.value)}
className="px-2.5 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
>
<option value="">{t('tasks.allCreators')}</option>
{users.map(m => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
)}
{/* Date presets */}
<DatePresetPicker
activePreset={activePreset}
onSelect={(from, to, key) => { setFilterDateFrom(from); setFilterDateTo(to); setActivePreset(key) }}
onClear={() => { setFilterDateFrom(''); setFilterDateTo(''); setActivePreset('') }}
/>
{/* Date range */}
<div className="flex items-center gap-1">
<input
type="date"
value={filterDateFrom}
onChange={e => { setFilterDateFrom(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodFrom')}
/>
<span className="text-text-tertiary text-xs">-</span>
<input
type="date"
value={filterDateTo}
onChange={e => { setFilterDateTo(e.target.value); setActivePreset('') }}
className="px-2 py-1.5 text-xs border border-border rounded-lg bg-white text-text-primary focus:outline-none focus:ring-1 focus:ring-brand-primary/20"
title={t('posts.periodTo')}
/>
</div>
{/* Overdue toggle */}
<button
onClick={() => setFilterOverdue(!filterOverdue)}
className={`px-2.5 py-1 text-[11px] font-medium rounded-full border transition-colors ${
filterOverdue
? 'bg-red-50 border-red-200 text-red-600'
: 'bg-white border-border text-text-tertiary'
}`}
>
{t('tasks.overdue')}
</button>
{/* Clear all */}
{hasActiveFilters && (
<button
onClick={clearFilters}
className="px-2.5 py-1 text-[11px] font-medium text-text-tertiary hover:text-text-primary transition-colors"
>
{t('tasks.clearFilters')}
</button>
)}
</div>
)}
{/* ─── Views ────────────────────────────────── */}
{filteredTasks.length === 0 ? (
<EmptyState
icon={CheckSquare}
title={tasks.length === 0 ? t('tasks.noTasks') : t('tasks.noMatch')}
description={tasks.length === 0 ? t('tasks.createFirst') : t('tasks.tryFilter')}
actionLabel={tasks.length === 0 ? t('tasks.createTask') : null}
onAction={tasks.length === 0 ? () => { setSelectedTask({ title: '', description: '', priority: 'medium', start_date: '', due_date: '', status: 'todo', assigned_to: '', project_id: '' }) } : null}
secondaryActionLabel={hasActiveFilters ? t('tasks.clearFilters') : null}
onSecondaryAction={clearFilters}
/>
) : (
<>
{/* ─── Board View ──────────────────────── */}
{viewMode === 'board' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
return (
<div key={col.status}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{col.items.length}
</span>
</div>
<div
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
>
{col.items.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
</div>
) : (
col.items.map(task => {
const canEdit = canEditResource('task', task)
const canDelete = canDeleteResource('task', task)
return (
<div
key={task._id || task.id}
draggable={canEdit}
onDragStart={(e) => canEdit && handleDragStart(e, task)}
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group" onClick={() => openTask(task)}>
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{canDelete && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handlePanelDelete(task._id || task.id) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
)
})}
</div>
)}
{/* ─── List View ───────────────────────── */}
{viewMode === 'list' && (
<div className="bg-white rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary/50">
<th className="w-8 px-3 py-2.5"></th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('title')}
>
{t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.project')}</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.brand')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('status')}
>
{t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase">{t('tasks.assignee')}</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('due_date')}
>
{t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left px-3 py-2.5 text-xs font-semibold text-text-tertiary uppercase cursor-pointer hover:text-text-primary"
onClick={() => toggleSort('priority')}
>
{t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{sortedListTasks.map(task => {
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
const dueDate = task.due_date || task.dueDate
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
const projectName = task.project_name || task.projectName
const brandName = task.brand_name || task.brandName
const assignedName = task.assigned_name || task.assignedName
const statusLabels = { todo: t('tasks.todo'), in_progress: t('tasks.in_progress'), done: t('tasks.done') }
const statusColors = { todo: 'bg-gray-100 text-gray-600', in_progress: 'bg-blue-100 text-blue-700', done: 'bg-emerald-100 text-emerald-700' }
return (
<tr
key={task._id || task.id}
onClick={() => openTask(task)}
className="border-b border-border-light hover:bg-surface-secondary/30 cursor-pointer transition-colors group"
>
<td className="px-3 py-2.5">
<div className={`w-2.5 h-2.5 rounded-full ${priority.color}`} title={priority.label} />
</td>
<td className="px-3 py-2.5">
<span className={`font-medium ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
{task.title}
</span>
{(task.comment_count || task.commentCount) > 0 && (
<span className="ml-2 text-[10px] text-text-tertiary">💬 {task.comment_count || task.commentCount}</span>
)}
</td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{projectName || '—'}</td>
<td className="px-3 py-2.5">
{brandName ? (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
) : <span className="text-text-tertiary text-xs"></span>}
</td>
<td className="px-3 py-2.5">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${statusColors[task.status] || ''}`}>
{statusLabels[task.status] || task.status}
</span>
</td>
<td className="px-3 py-2.5 text-text-tertiary text-xs">{assignedName || t('common.unassigned')}</td>
<td className="px-3 py-2.5">
{dueDate ? (
<span className={`text-xs ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
{format(new Date(dueDate), 'MMM d, yyyy')}
</span>
) : <span className="text-text-tertiary text-xs"></span>}
</td>
<td className="px-3 py-2.5">
<span className="text-xs text-text-tertiary">{priority.label}</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
{/* ─── Calendar View ───────────────────── */}
{viewMode === 'calendar' && (
<TaskCalendarView tasks={filteredTasks} onTaskClick={openTask} />
)}
</>
)}
{/* ─── Task Detail Side Panel ──────────────── */}
{selectedTask && (
<TaskDetailPanel
task={selectedTask}
onClose={() => setSelectedTask(null)}
onSave={handlePanelSave}
onDelete={canDeleteResource('task', selectedTask) ? handlePanelDelete : undefined}
projects={projects}
users={assignableUsers}
brands={brands}
/>
)}
</div>
)
}