import { useState, useEffect, useCallback, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import api from '../services/api' import { KanbanBoard } from '../components/KanbanBoard' import { CalendarView } from '../components/CalendarView' import { GanttChart } from '../components/GanttChart' import { TaskDetailModal } from '../components/TaskDetailModal' import { UserSelect } from '../components/UserSelect' import { UserSearchResult } from '../services/collaboration' import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext' import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields' import { CustomFieldInput } from '../components/CustomFieldInput' import { SkeletonTable, SkeletonKanban, Skeleton } from '../components/Skeleton' import { logger } from '../utils/logger' interface Task { id: string project_id: string title: string description: string | null priority: string status_id: string | null status_name: string | null status_color: string | null assignee_id: string | null assignee_name: string | null due_date: string | null start_date: string | null time_estimate: number | null subtask_count: number parent_task_id: string | null custom_values?: CustomValueResponse[] version?: number } interface TaskStatus { id: string name: string color: string is_done: boolean } interface Project { id: string title: string space_id: string } type ViewMode = 'list' | 'kanban' | 'calendar' | 'gantt' const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode' const COLUMN_VISIBILITY_STORAGE_KEY = 'tasks-column-visibility' const MOBILE_BREAKPOINT = 768 // Get column visibility settings from localStorage const getColumnVisibility = (projectId: string): Record => { try { const saved = localStorage.getItem(`${COLUMN_VISIBILITY_STORAGE_KEY}-${projectId}`) return saved ? JSON.parse(saved) : {} } catch { return {} } } // Save column visibility settings to localStorage const saveColumnVisibility = (projectId: string, visibility: Record) => { localStorage.setItem(`${COLUMN_VISIBILITY_STORAGE_KEY}-${projectId}`, JSON.stringify(visibility)) } export default function Tasks() { const { t, i18n } = useTranslation('tasks') const { projectId } = useParams() const navigate = useNavigate() const { subscribeToProject, unsubscribeFromProject, addTaskEventListener, isConnected } = useProjectSync() const [project, setProject] = useState(null) const [tasks, setTasks] = useState([]) const [statuses, setStatuses] = useState([]) const [loading, setLoading] = useState(true) const [showCreateModal, setShowCreateModal] = useState(false) const [isMobile, setIsMobile] = useState(false) const [viewMode, setViewMode] = useState(() => { const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY) return (saved === 'kanban' || saved === 'list' || saved === 'calendar' || saved === 'gantt') ? saved : 'list' }) const [newTask, setNewTask] = useState({ title: '', description: '', priority: 'medium', assignee_id: '', start_date: '', due_date: '', time_estimate: '', }) const [, setSelectedAssignee] = useState(null) const [creating, setCreating] = useState(false) const [selectedTask, setSelectedTask] = useState(null) const [showDetailModal, setShowDetailModal] = useState(false) // Custom fields state const [customFields, setCustomFields] = useState([]) const [newTaskCustomValues, setNewTaskCustomValues] = useState>({}) // Column visibility state const [columnVisibility, setColumnVisibility] = useState>(() => { return projectId ? getColumnVisibility(projectId) : {} }) const [showColumnMenu, setShowColumnMenu] = useState(false) const columnMenuRef = useRef(null) const createModalOverlayRef = useRef(null) // Detect mobile viewport useEffect(() => { const checkMobile = () => { setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) } checkMobile() window.addEventListener('resize', checkMobile) return () => window.removeEventListener('resize', checkMobile) }, []) useEffect(() => { loadData() }, [projectId]) // Load custom fields when project changes useEffect(() => { if (projectId) { loadCustomFields() } }, [projectId]) const loadCustomFields = async () => { try { const response = await customFieldsApi.getCustomFields(projectId!) setCustomFields(response.fields) } catch (err) { logger.error('Failed to load custom fields:', err) } } // Subscribe to project WebSocket when project changes // Use isMounted ref to handle React StrictMode's double-mount behavior const isMountedRef = useRef(true) useEffect(() => { isMountedRef.current = true if (projectId) { // Small delay to avoid race conditions with StrictMode's rapid mount/unmount const timeoutId = setTimeout(() => { if (isMountedRef.current) { subscribeToProject(projectId) } }, 100) return () => { clearTimeout(timeoutId) isMountedRef.current = false unsubscribeFromProject() } } return () => { isMountedRef.current = false } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projectId]) // Handle real-time task events from WebSocket const handleTaskEvent = useCallback((event: TaskEvent) => { switch (event.type) { case 'task_created': // Add new task to list setTasks((prev) => { // Check if task already exists (avoid duplicates) if (prev.some((t) => t.id === event.data.task_id)) { return prev } const newTask: Task = { id: event.data.task_id, project_id: projectId!, title: event.data.title || '', description: event.data.description ?? null, priority: event.data.priority || 'medium', status_id: event.data.status_id ?? null, status_name: event.data.status_name ?? null, status_color: event.data.status_color ?? null, assignee_id: event.data.assignee_id ?? null, assignee_name: event.data.assignee_name ?? null, due_date: event.data.due_date ?? null, start_date: (event.data.start_date as string) ?? null, time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null, subtask_count: event.data.subtask_count ?? 0, parent_task_id: (event.data.parent_task_id as string) ?? null, } return [...prev, newTask] }) break case 'task_updated': case 'task_status_changed': case 'task_assigned': // Update existing task setTasks((prev) => prev.map((task) => { if (task.id !== event.data.task_id) return task // Merge event data into existing task return { ...task, ...(event.data.title !== undefined && { title: event.data.title }), ...(event.data.description !== undefined && { description: event.data.description ?? null }), ...(event.data.priority !== undefined && { priority: event.data.priority }), ...(event.data.status_id !== undefined && { status_id: event.data.status_id ?? null }), ...(event.data.status_name !== undefined && { status_name: event.data.status_name ?? null }), ...(event.data.status_color !== undefined && { status_color: event.data.status_color ?? null }), ...(event.data.new_status_id !== undefined && { status_id: event.data.new_status_id ?? null }), ...(event.data.new_status_name !== undefined && { status_name: event.data.new_status_name ?? null }), ...(event.data.new_status_color !== undefined && { status_color: event.data.new_status_color ?? null }), ...(event.data.assignee_id !== undefined && { assignee_id: event.data.assignee_id ?? null }), ...(event.data.assignee_name !== undefined && { assignee_name: event.data.assignee_name ?? null }), ...(event.data.new_assignee_id !== undefined && { assignee_id: event.data.new_assignee_id ?? null }), ...(event.data.new_assignee_name !== undefined && { assignee_name: event.data.new_assignee_name ?? null }), ...(event.data.due_date !== undefined && { due_date: event.data.due_date ?? null }), ...(event.data.start_date !== undefined && { start_date: (event.data.start_date as string) ?? null }), ...(event.data.time_estimate !== undefined && { time_estimate: event.data.time_estimate ?? null }), ...(event.data.original_estimate !== undefined && event.data.time_estimate === undefined && { time_estimate: event.data.original_estimate ?? null }), ...(event.data.subtask_count !== undefined && { subtask_count: event.data.subtask_count }), } }) ) break case 'task_deleted': // Remove task from list setTasks((prev) => prev.filter((task) => task.id !== event.data.task_id)) break } }, []) useEffect(() => { const unsubscribe = addTaskEventListener(handleTaskEvent) return unsubscribe }, [addTaskEventListener, handleTaskEvent]) // Persist view mode useEffect(() => { localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode) }, [viewMode]) // Load column visibility when projectId changes useEffect(() => { if (projectId) { setColumnVisibility(getColumnVisibility(projectId)) } }, [projectId]) // Check if a custom field column is visible (default to true if not set) const isColumnVisible = (fieldId: string): boolean => { return columnVisibility[fieldId] !== false } // Toggle column visibility const toggleColumnVisibility = (fieldId: string) => { const newVisibility = { ...columnVisibility, [fieldId]: !isColumnVisible(fieldId), } setColumnVisibility(newVisibility) if (projectId) { saveColumnVisibility(projectId, newVisibility) } } // Close column menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (columnMenuRef.current && !columnMenuRef.current.contains(event.target as Node)) { setShowColumnMenu(false) } } if (showColumnMenu) { document.addEventListener('mousedown', handleClickOutside) } return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [showColumnMenu]) // Handle Escape key to close create modal - document-level listener useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape' && showCreateModal) { setShowCreateModal(false) } } if (showCreateModal) { document.addEventListener('keydown', handleKeyDown) // Focus the overlay for accessibility createModalOverlayRef.current?.focus() } return () => { document.removeEventListener('keydown', handleKeyDown) } }, [showCreateModal]) const loadData = async () => { try { const [projectRes, tasksRes, statusesRes] = await Promise.all([ api.get(`/projects/${projectId}`), api.get(`/projects/${projectId}/tasks`), api.get(`/projects/${projectId}/statuses`), ]) setProject(projectRes.data) setTasks(tasksRes.data.tasks) setStatuses(statusesRes.data) } catch (err) { logger.error('Failed to load data:', err) } finally { setLoading(false) } } const handleCreateTask = async () => { if (!newTask.title.trim()) return setCreating(true) try { const payload: Record = { title: newTask.title, description: newTask.description || null, priority: newTask.priority, } if (newTask.assignee_id) { payload.assignee_id = newTask.assignee_id } if (newTask.start_date) { // Convert date string to datetime format for Pydantic payload.start_date = `${newTask.start_date}T00:00:00` } if (newTask.due_date) { // Convert date string to datetime format for Pydantic payload.due_date = `${newTask.due_date}T00:00:00` } if (newTask.time_estimate) { payload.original_estimate = Number(newTask.time_estimate) } // Include custom field values (only non-formula fields) const customValuesPayload = Object.entries(newTaskCustomValues) .filter(([fieldId, value]) => { const field = customFields.find((f) => f.id === fieldId) return field && field.field_type !== 'formula' && value !== null }) .map(([fieldId, value]) => ({ field_id: fieldId, value: value, })) if (customValuesPayload.length > 0) { payload.custom_values = customValuesPayload } await api.post(`/projects/${projectId}/tasks`, payload) setShowCreateModal(false) setNewTask({ title: '', description: '', priority: 'medium', assignee_id: '', start_date: '', due_date: '', time_estimate: '', }) setNewTaskCustomValues({}) setSelectedAssignee(null) loadData() } catch (err) { logger.error('Failed to create task:', err) } finally { setCreating(false) } } const handleNewTaskCustomFieldChange = (fieldId: string, value: string | number | null) => { setNewTaskCustomValues((prev) => ({ ...prev, [fieldId]: value, })) } const handleStatusChange = async (taskId: string, statusId: string) => { // Save original state for rollback const originalTasks = [...tasks] // Find the target status for optimistic update const targetStatus = statuses.find((s) => s.id === statusId) // Optimistic update setTasks((prev) => prev.map((task) => task.id === taskId ? { ...task, status_id: statusId, status_name: targetStatus?.name ?? null, status_color: targetStatus?.color ?? null, } : task ) ) try { await api.patch(`/tasks/${taskId}/status`, { status_id: statusId }) // Success - real-time event from WebSocket will be ignored (triggered_by check) } catch (err) { // Rollback on error setTasks(originalTasks) logger.error('Failed to update status:', err) // Could add toast notification here for better UX } } const handleTaskClick = (task: Task) => { // Ensure task has project_id for custom fields loading const taskWithProject = { ...task, project_id: projectId!, } setSelectedTask(taskWithProject) setShowDetailModal(true) } const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => { setNewTask({ ...newTask, assignee_id: userId || '' }) setSelectedAssignee(user) } const handleCloseDetailModal = () => { setShowDetailModal(false) setSelectedTask(null) } const handleTaskUpdate = async () => { await loadData() // If a task is selected (modal is open), re-fetch its data to show updated values if (selectedTask) { try { const response = await api.get(`/tasks/${selectedTask.id}`) const updatedTask = response.data setSelectedTask({ ...updatedTask, project_id: projectId!, time_estimate: updatedTask.original_estimate, }) } catch (err) { logger.error('Failed to refresh selected task:', err) } } } const handleSubtaskClick = async (subtaskId: string) => { try { const response = await api.get(`/tasks/${subtaskId}`) const subtask = response.data // Ensure subtask has project_id for custom fields loading const subtaskWithProject = { ...subtask, project_id: projectId!, // Map API response fields to frontend Task interface time_estimate: subtask.original_estimate, } setSelectedTask(subtaskWithProject) // Modal is already open, just update the task } catch (err) { logger.error('Failed to load subtask:', err) } } const getPriorityColor = (priority: string): string => { const colors: { [key: string]: string } = { low: '#808080', medium: '#0066cc', high: '#ff9800', urgent: '#f44336', } return colors[priority] || colors.medium } const getPriorityStyle = (priority: string): React.CSSProperties => { return { width: '4px', backgroundColor: getPriorityColor(priority), borderRadius: '2px', } } // Format date according to locale const formatDate = (dateString: string): string => { const date = new Date(dateString) return date.toLocaleDateString(i18n.language, { year: 'numeric', month: 'short', day: 'numeric', }) } // Render task card for mobile view const renderTaskCard = (task: Task) => (
handleTaskClick(task)} >

{task.title}

{task.description && (

{task.description.length > 100 ? task.description.substring(0, 100) + '...' : task.description}

)}
{task.assignee_name && ( {task.assignee_name} )} {task.due_date && ( {t('fields.dueDate')}: {formatDate(task.due_date)} )}
{task.status_name || t('status.noStatus')}
{task.subtask_count > 0 && ( {t('subtasks.count', { count: task.subtask_count })} )} {task.time_estimate && ( {t('fields.hours', { count: task.time_estimate })} )}
{/* Mobile status change action */}
) if (loading) { return (
{viewMode === 'kanban' ? : }
) } return (
navigate('/spaces')} style={styles.breadcrumbLink}> {t('common:nav.spaces')} / navigate(`/spaces/${project?.space_id}`)} style={styles.breadcrumbLink} > {t('common:nav.projects')} / {project?.title}

{t('title')}

{isConnected ? ( {'\u25CF'} {t('common:labels.live')} ) : projectId ? ( {'\u25CB'} {t('common:labels.offline')} ) : null}
{/* View Toggle */}
{!isMobile && ( )}
{/* Column Visibility Toggle - only show when there are custom fields and in list view */} {!isMobile && viewMode === 'list' && customFields.length > 0 && (
{showColumnMenu && (
{t('settings:customFields.title')}
{customFields.map((field) => ( ))} {customFields.length === 0 && (
{t('common:labels.noData')}
)}
)}
)} {!isMobile && ( )}
{/* Conditional rendering based on view mode */} {viewMode === 'list' && ( isMobile ? ( // Mobile card view
{tasks.map(renderTaskCard)} {tasks.length === 0 && (

{t('empty.description')}

)}
) : ( // Desktop list view with horizontal scroll for wide tables
{tasks.map((task) => (
handleTaskClick(task)} >
{task.title}
{task.assignee_name && ( {task.assignee_name} )} {task.due_date && ( {t('fields.dueDate')}: {formatDate(task.due_date)} )} {task.time_estimate && ( {t('fields.hours', { count: task.time_estimate })} )} {task.subtask_count > 0 && ( {t('subtasks.count', { count: task.subtask_count })} )} {/* Display visible custom field values */} {task.custom_values && task.custom_values .filter((cv) => isColumnVisible(cv.field_id)) .map((cv) => ( {cv.field_name}: {cv.display_value || cv.value || '-'} ))}
))} {tasks.length === 0 && (

{t('empty.description')}

)}
) )} {viewMode === 'kanban' && (
)} {viewMode === 'calendar' && projectId && ( )} {viewMode === 'gantt' && projectId && !isMobile && ( )} {/* Create Task Modal */} {showCreateModal && (

{t('createTask')}

setNewTask({ ...newTask, title: e.target.value })} style={styles.input} autoFocus />