import { useState, useMemo, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { format, isPast, parseISO } from 'date-fns'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ArrowLeft, Plus, Trash2, ListChecks, Pencil, Pin, Calendar, CheckCircle2, PlayCircle, AlertTriangle, List, Columns3, ArrowUpDown, } from 'lucide-react'; import api from '@/lib/api'; import type { Project, ProjectTask } from '@/types'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; import { Select } from '@/components/ui/select'; import { ListSkeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; import TaskRow from './TaskRow'; import TaskDetailPanel from './TaskDetailPanel'; import KanbanBoard from './KanbanBoard'; import TaskForm from './TaskForm'; import ProjectForm from './ProjectForm'; import { statusColors, statusLabels } from './constants'; type SortMode = 'manual' | 'priority' | 'due_date'; type ViewMode = 'list' | 'kanban'; const PRIORITY_ORDER: Record = { high: 0, medium: 1, low: 2, none: 3 }; function SortableTaskRow({ task, isSelected, isExpanded, showDragHandle, onSelect, onToggleExpand, onToggleStatus, togglePending, }: { task: ProjectTask; isSelected: boolean; isExpanded: boolean; showDragHandle: boolean; onSelect: () => void; onToggleExpand: () => void; onToggleStatus: () => void; togglePending: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: task.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; return (
); } export default function ProjectDetail() { const { id } = useParams(); const navigate = useNavigate(); const queryClient = useQueryClient(); const [showTaskForm, setShowTaskForm] = useState(false); const [showProjectForm, setShowProjectForm] = useState(false); const [editingTask, setEditingTask] = useState(null); const [subtaskParentId, setSubtaskParentId] = useState(null); const [expandedTasks, setExpandedTasks] = useState>(new Set()); const [selectedTaskId, setSelectedTaskId] = useState(null); const [kanbanParentTaskId, setKanbanParentTaskId] = useState(null); const [sortMode, setSortMode] = useState('manual'); const [viewMode, setViewMode] = useState('list'); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor) ); const toggleExpand = (taskId: number) => { setExpandedTasks((prev) => { const next = new Set(prev); if (next.has(taskId)) next.delete(taskId); else next.add(taskId); return next; }); }; const { data: project, isLoading } = useQuery({ queryKey: ['projects', id], queryFn: async () => { const { data } = await api.get(`/projects/${id}`); return data; }, }); const toggleTaskMutation = useMutation({ mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { const newStatus = status === 'completed' ? 'pending' : 'completed'; const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status: newStatus }); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, onError: () => { toast.error('Failed to update task'); }, }); const deleteTaskMutation = useMutation({ mutationFn: async (taskId: number) => { await api.delete(`/projects/${id}/tasks/${taskId}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); toast.success('Task deleted'); setSelectedTaskId(null); }, }); const deleteProjectMutation = useMutation({ mutationFn: async () => { await api.delete(`/projects/${id}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); toast.success('Project deleted'); navigate('/projects'); }, onError: () => { toast.error('Failed to delete project'); }, }); const toggleTrackMutation = useMutation({ mutationFn: async () => { const { data } = await api.put(`/projects/${id}`, { is_tracked: !project?.is_tracked }); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects'] }); queryClient.invalidateQueries({ queryKey: ['projects', id] }); queryClient.invalidateQueries({ queryKey: ['tracked-tasks'] }); toast.success(project?.is_tracked ? 'Project untracked' : 'Project tracked'); }, onError: () => { toast.error('Failed to update tracking'); }, }); const reorderMutation = useMutation({ mutationFn: async (items: { id: number; sort_order: number }[]) => { await api.put(`/projects/${id}/tasks/reorder`, items); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, }); const updateTaskStatusMutation = useMutation({ mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status }); return data; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, onError: () => { toast.error('Failed to update task status'); }, }); const allTasks = project?.tasks || []; const topLevelTasks = useMemo( () => allTasks.filter((t) => !t.parent_task_id), [allTasks] ); const sortSubtasks = useCallback( (subtasks: ProjectTask[]): ProjectTask[] => { if (sortMode === 'manual') return subtasks; const sorted = [...subtasks]; if (sortMode === 'priority') { sorted.sort((a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3)); } else if (sortMode === 'due_date') { sorted.sort((a, b) => { if (!a.due_date && !b.due_date) return 0; if (!a.due_date) return 1; if (!b.due_date) return -1; return a.due_date.localeCompare(b.due_date); }); } return sorted; }, [sortMode] ); const sortedTasks = useMemo(() => { const tasks = [...topLevelTasks].map((t) => ({ ...t, subtasks: sortSubtasks(t.subtasks || []), })); switch (sortMode) { case 'priority': return tasks.sort( (a, b) => (PRIORITY_ORDER[a.priority] ?? 3) - (PRIORITY_ORDER[b.priority] ?? 3) ); case 'due_date': return tasks.sort((a, b) => { if (!a.due_date && !b.due_date) return 0; if (!a.due_date) return 1; if (!b.due_date) return -1; return a.due_date.localeCompare(b.due_date); }); case 'manual': default: return tasks.sort((a, b) => a.sort_order - b.sort_order); } }, [topLevelTasks, sortMode, sortSubtasks]); const selectedTask = useMemo(() => { if (!selectedTaskId) return null; // Search top-level and subtasks for (const task of allTasks) { if (task.id === selectedTaskId) return task; if (task.subtasks) { const sub = task.subtasks.find((s) => s.id === selectedTaskId); if (sub) return sub; } } return null; }, [selectedTaskId, allTasks]); const kanbanParentTask = useMemo(() => { if (!kanbanParentTaskId) return null; return topLevelTasks.find((t) => t.id === kanbanParentTaskId) || null; }, [kanbanParentTaskId, topLevelTasks]); const handleKanbanSelectTask = useCallback( (taskId: number) => { setSelectedTaskId(taskId); // Only enter subtask view when clicking a top-level task with subtasks // and we're not already in subtask view if (!kanbanParentTaskId) { const task = topLevelTasks.find((t) => t.id === taskId); if (task && task.subtasks && task.subtasks.length > 0) { setKanbanParentTaskId(taskId); } } }, [kanbanParentTaskId, topLevelTasks] ); const handleBackToAllTasks = useCallback(() => { setKanbanParentTaskId(null); setSelectedTaskId(null); }, []); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = sortedTasks.findIndex((t) => t.id === active.id); const newIndex = sortedTasks.findIndex((t) => t.id === over.id); const reordered = arrayMove(sortedTasks, oldIndex, newIndex); const items = reordered.map((task, index) => ({ id: task.id, sort_order: index, })); // Optimistic update queryClient.setQueryData(['projects', id], (old: Project | undefined) => { if (!old) return old; const updated = { ...old, tasks: [...old.tasks] }; for (const item of items) { const t = updated.tasks.find((tt) => tt.id === item.id); if (t) t.sort_order = item.sort_order; } return updated; }); reorderMutation.mutate(items); }, [sortedTasks, id, queryClient, reorderMutation] ); const openTaskForm = (task: ProjectTask | null, parentId: number | null) => { setEditingTask(task); setSubtaskParentId(parentId); setShowTaskForm(true); }; const closeTaskForm = () => { setShowTaskForm(false); setEditingTask(null); setSubtaskParentId(null); }; const handleDeleteTask = (taskId: number) => { if (!window.confirm('Delete this task and all its subtasks?')) return; deleteTaskMutation.mutate(taskId); }; if (isLoading) { return (

Loading...

); } if (!project) { return
Project not found
; } const completedTasks = allTasks.filter((t) => t.status === 'completed').length; const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length; const overdueTasks = allTasks.filter( (t) => t.due_date && t.status !== 'completed' && isPast(parseISO(t.due_date)) ).length; const totalTasks = allTasks.length; const progressPercent = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 0; const isProjectOverdue = project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date)); return (
{/* Header */}

{project.name}

{statusLabels[project.status]}
{/* Content area */}
{/* Summary section - scrolls with left panel on small, fixed on large */}
{/* Description */} {project.description && (

{project.description}

)} {/* Project Summary Card */}
Overall Progress {Math.round(progressPercent)}%

{completedTasks} of {totalTasks} tasks completed

{totalTasks}

Total

{inProgressTasks}

Active

{completedTasks}

Done

{overdueTasks > 0 && (

{overdueTasks}

Overdue

)}
{project.due_date && (
Due {format(parseISO(project.due_date), 'MMM d, yyyy')} {isProjectOverdue && (Overdue)}
)}
{/* Task list header + view controls */}

Tasks

{/* View toggle */}
{/* Sort dropdown (list view only) */} {viewMode === 'list' && (
)}
{/* Main content: task list/kanban + detail panel */}
{/* Left panel: task list or kanban */}
{topLevelTasks.length === 0 ? ( openTaskForm(null, null)} /> ) : viewMode === 'kanban' ? ( updateTaskStatusMutation.mutate({ taskId, status }) } onBackToAllTasks={handleBackToAllTasks} /> ) : ( t.id)} strategy={verticalListSortingStrategy} disabled={sortMode !== 'manual'} >
{sortedTasks.map((task) => { const isExpanded = expandedTasks.has(task.id); const hasSubtasks = task.subtasks && task.subtasks.length > 0; return (
setSelectedTaskId(task.id)} onToggleExpand={() => toggleExpand(task.id)} onToggleStatus={() => toggleTaskMutation.mutate({ taskId: task.id, status: task.status, }) } togglePending={toggleTaskMutation.isPending} /> {/* Expanded subtasks */} {isExpanded && hasSubtasks && (
{task.subtasks.map((subtask) => ( setSelectedTaskId(subtask.id)} onToggleExpand={() => {}} onToggleStatus={() => toggleTaskMutation.mutate({ taskId: subtask.id, status: subtask.status, }) } togglePending={toggleTaskMutation.isPending} /> ))}
)}
); })}
)}
{/* Right panel: task detail (hidden on small screens) */}
openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)} onSelectTask={setSelectedTaskId} />
{/* Mobile: show detail panel as overlay when task selected on small screens */} {selectedTaskId && selectedTask && (
Task Details
openTaskForm(null, parentId)} onClose={() => setSelectedTaskId(null)} onSelectTask={setSelectedTaskId} />
)} {showTaskForm && ( t.id === subtaskParentId)?.due_date : project?.due_date } onClose={closeTaskForm} /> )} {showProjectForm && ( setShowProjectForm(false)} /> )}
); }