import { useState, useEffect, useContext, useMemo } from 'react' import { Plus, CheckSquare, 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 KanbanBoard from '../components/KanbanBoard' import KanbanCard from '../components/KanbanCard' import TaskDetailPanel from '../components/TaskDetailPanel' import BulkSelectBar from '../components/BulkSelectBar' import Modal from '../components/Modal' import TaskCalendarView from '../components/TaskCalendarView' import DatePresetPicker from '../components/DatePresetPicker' import EmptyState from '../components/EmptyState' import { useToast } from '../components/ToastContainer' import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader' 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) // 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) const [selectedIds, setSelectedIds] = useState(new Set()) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false) const [createForm, setCreateForm] = useState({ title: '', project_id: '', brand_id: '', priority: 'medium', assigned_to: '' }) const [createSaving, setCreateSaving] = 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(Array.isArray(res) ? res : [])).catch(() => {}) api.get('/projects').then(res => setProjects(Array.isArray(res) ? res : [])).catch(() => {}) if (isSuperadmin) { api.get('/team').then(res => setUsers(Array.isArray(res) ? res : [])).catch(() => {}) } }, [isSuperadmin]) const loadTasks = async () => { try { const res = await api.get('/tasks') setTasks(Array.isArray(res) ? 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 handleCreateTask = async () => { setCreateSaving(true) try { const data = { title: createForm.title, priority: createForm.priority, status: 'todo', project_id: createForm.project_id ? Number(createForm.project_id) : null, brand_id: createForm.brand_id ? Number(createForm.brand_id) : null, assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null, is_personal: false, } const created = await api.post('/tasks', data) setShowCreateModal(false) toast.success(t('tasks.created')) loadTasks() // Open detail panel for further editing if (created) setSelectedTask(created) } catch (err) { console.error('Create task failed:', err) toast.error(t('common.saveFailed')) } finally { setCreateSaving(false) } } const handleBulkDelete = async () => { try { await api.post('/tasks/bulk-delete', { ids: [...selectedIds] }) toast.success(t('tasks.deleted')) setSelectedIds(new Set()) setShowBulkDeleteConfirm(false) loadTasks() } catch (err) { console.error('Bulk delete failed:', err) toast.error(t('common.deleteFailed')) } } const toggleSelect = (id) => { setSelectedIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const toggleSelectAll = () => { if (selectedIds.size === sortedListTasks.length) setSelectedIds(new Set()) else setSelectedIds(new Set(sortedListTasks.map(t => t._id || t.id))) } const handleMove = async (taskId, newStatus) => { // Optimistic update — move the card instantly const prev = tasks setTasks(tasks.map(t => (t._id || t.id) === taskId ? { ...t, status: newStatus } : t)) try { await api.patch(`/tasks/${taskId}`, { status: newStatus }) toast.success(t('tasks.statusUpdated')) } catch (err) { setTasks(prev) if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn')) else toast.error(t('common.updateFailed')) } } const openTask = (task) => { setSelectedTask(task) } // ─── 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 (
| 0 && selectedIds.size === sortedListTasks.length} onChange={toggleSelectAll} className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer" onClick={e => e.stopPropagation()} /> | toggleSort('title')} > {t('tasks.taskTitle')} {sortBy === 'title' && (sortDir === 'asc' ? '↑' : '↓')} | {t('tasks.project')} | {t('tasks.brand')} | toggleSort('status')} > {t('tasks.status')} {sortBy === 'status' && (sortDir === 'asc' ? '↑' : '↓')} | {t('tasks.assignee')} | toggleSort('due_date')} > {t('tasks.dueDate')} {sortBy === 'due_date' && (sortDir === 'asc' ? '↑' : '↓')} | toggleSort('priority')} > {t('tasks.priority')} {sortBy === 'priority' && (sortDir === 'asc' ? '↑' : '↓')} | |
|---|---|---|---|---|---|---|---|---|
| e.stopPropagation()}> toggleSelect(task._id || task.id)} className="w-3.5 h-3.5 rounded border-border text-brand-primary cursor-pointer" /> | {task.title} {(task.comment_count || task.commentCount) > 0 && ( 💬 {task.comment_count || task.commentCount} )} | {projectName || '—'} | {brandName ? ( {brandName} ) : —} | {statusLabels[task.status] || task.status} | {assignedName || t('common.unassigned')} | {dueDate ? ( {format(new Date(dueDate), 'MMM d, yyyy')} ) : —} | {priority.label} |