diff --git a/backend/alembic/versions/009_add_task_comments.py b/backend/alembic/versions/009_add_task_comments.py new file mode 100644 index 0000000..cb280e4 --- /dev/null +++ b/backend/alembic/versions/009_add_task_comments.py @@ -0,0 +1,36 @@ +"""add task comments + +Revision ID: 009 +Revises: 008 +Create Date: 2026-02-22 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "009" +down_revision = "008" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "task_comments", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column( + "task_id", + sa.Integer(), + sa.ForeignKey("project_tasks.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index("ix_task_comments_task_id", "task_comments", ["task_id"]) + + +def downgrade() -> None: + op.drop_index("ix_task_comments_task_id", table_name="task_comments") + op.drop_table("task_comments") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c8d3e31..4c5854c 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.project import Project from app.models.project_task import ProjectTask from app.models.person import Person from app.models.location import Location +from app.models.task_comment import TaskComment __all__ = [ "Settings", @@ -18,4 +19,5 @@ __all__ = [ "ProjectTask", "Person", "Location", + "TaskComment", ] diff --git a/backend/app/models/project_task.py b/backend/app/models/project_task.py index ea08384..6d52be5 100644 --- a/backend/app/models/project_task.py +++ b/backend/app/models/project_task.py @@ -34,3 +34,7 @@ class ProjectTask(Base): back_populates="parent_task", cascade="all, delete-orphan", ) + comments: Mapped[List["TaskComment"]] = sa_relationship( + back_populates="task", + cascade="all, delete-orphan", + ) diff --git a/backend/app/models/task_comment.py b/backend/app/models/task_comment.py new file mode 100644 index 0000000..3a11516 --- /dev/null +++ b/backend/app/models/task_comment.py @@ -0,0 +1,18 @@ +from sqlalchemy import Text, Integer, ForeignKey, func +from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship +from datetime import datetime +from app.database import Base + + +class TaskComment(Base): + __tablename__ = "task_comments" + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + task_id: Mapped[int] = mapped_column( + Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True + ) + content: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + # Relationships + task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments") diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index cba5762..37f9d54 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -3,21 +3,42 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from sqlalchemy.orm import selectinload from typing import List +from pydantic import BaseModel from app.database import get_db from app.models.project import Project from app.models.project_task import ProjectTask +from app.models.task_comment import TaskComment from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse +from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse from app.routers.auth import get_current_session from app.models.settings import Settings router = APIRouter() -def _project_with_tasks(): - """Eager load projects with tasks and their subtasks.""" - return selectinload(Project.tasks).selectinload(ProjectTask.subtasks) +class ReorderItem(BaseModel): + id: int + sort_order: int + + +def _project_load_options(): + """All load options needed for project responses (tasks + subtasks + comments at each level).""" + return [ + selectinload(Project.tasks).selectinload(ProjectTask.comments), + selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), + selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), + ] + + +def _task_load_options(): + """All load options needed for task responses.""" + return [ + selectinload(ProjectTask.comments), + selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments), + selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks), + ] @router.get("/", response_model=List[ProjectResponse]) @@ -26,9 +47,9 @@ async def get_projects( current_user: Settings = Depends(get_current_session) ): """Get all projects with their tasks.""" - query = select(Project).options(_project_with_tasks()).order_by(Project.created_at.desc()) + query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc()) result = await db.execute(query) - projects = result.scalars().all() + projects = result.scalars().unique().all() return projects @@ -45,7 +66,7 @@ async def create_project( await db.commit() # Re-fetch with eagerly loaded tasks for response serialization - query = select(Project).options(_project_with_tasks()).where(Project.id == new_project.id) + query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id) result = await db.execute(query) return result.scalar_one() @@ -57,7 +78,7 @@ async def get_project( current_user: Settings = Depends(get_current_session) ): """Get a specific project by ID with its tasks.""" - query = select(Project).options(_project_with_tasks()).where(Project.id == project_id) + query = select(Project).options(*_project_load_options()).where(Project.id == project_id) result = await db.execute(query) project = result.scalar_one_or_none() @@ -89,7 +110,7 @@ async def update_project( await db.commit() # Re-fetch with eagerly loaded tasks for response serialization - query = select(Project).options(_project_with_tasks()).where(Project.id == project_id) + query = select(Project).options(*_project_load_options()).where(Project.id == project_id) result = await db.execute(query) return result.scalar_one() @@ -128,7 +149,7 @@ async def get_project_tasks( query = ( select(ProjectTask) - .options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks)) + .options(*_task_load_options()) .where( ProjectTask.project_id == project_id, ProjectTask.parent_task_id.is_(None), @@ -136,7 +157,7 @@ async def get_project_tasks( .order_by(ProjectTask.sort_order.asc()) ) result = await db.execute(query) - tasks = result.scalars().all() + tasks = result.scalars().unique().all() return tasks @@ -180,13 +201,43 @@ async def create_project_task( # Re-fetch with subtasks loaded query = ( select(ProjectTask) - .options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks)) + .options(*_task_load_options()) .where(ProjectTask.id == new_task.id) ) result = await db.execute(query) return result.scalar_one() +@router.put("/{project_id}/tasks/reorder", status_code=200) +async def reorder_tasks( + project_id: int, + items: List[ReorderItem], + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Bulk update sort_order for tasks.""" + result = await db.execute(select(Project).where(Project.id == project_id)) + project = result.scalar_one_or_none() + + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + for item in items: + task_result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == item.id, + ProjectTask.project_id == project_id + ) + ) + task = task_result.scalar_one_or_none() + if task: + task.sort_order = item.sort_order + + await db.commit() + + return {"status": "ok"} + + @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) async def update_project_task( project_id: int, @@ -217,7 +268,7 @@ async def update_project_task( # Re-fetch with subtasks loaded query = ( select(ProjectTask) - .options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks)) + .options(*_task_load_options()) .where(ProjectTask.id == task_id) ) result = await db.execute(query) @@ -247,3 +298,57 @@ async def delete_project_task( await db.commit() return None + + +@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201) +async def create_task_comment( + project_id: int, + task_id: int, + comment: TaskCommentCreate, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Add a comment to a task.""" + result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == task_id, + ProjectTask.project_id == project_id + ) + ) + task = result.scalar_one_or_none() + + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + new_comment = TaskComment(task_id=task_id, content=comment.content) + db.add(new_comment) + await db.commit() + await db.refresh(new_comment) + + return new_comment + + +@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204) +async def delete_task_comment( + project_id: int, + task_id: int, + comment_id: int, + db: AsyncSession = Depends(get_db), + current_user: Settings = Depends(get_current_session) +): + """Delete a task comment.""" + result = await db.execute( + select(TaskComment).where( + TaskComment.id == comment_id, + TaskComment.task_id == task_id + ) + ) + comment = result.scalar_one_or_none() + + if not comment: + raise HTTPException(status_code=404, detail="Comment not found") + + await db.delete(comment) + await db.commit() + + return None diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 2696c4f..9a407af 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime, date from typing import Optional, List, Literal +from app.schemas.task_comment import TaskCommentResponse TaskStatus = Literal["pending", "in_progress", "completed"] TaskPriority = Literal["low", "medium", "high"] @@ -41,6 +42,7 @@ class ProjectTaskResponse(BaseModel): created_at: datetime updated_at: datetime subtasks: List["ProjectTaskResponse"] = [] + comments: List[TaskCommentResponse] = [] model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/task_comment.py b/backend/app/schemas/task_comment.py new file mode 100644 index 0000000..5b3dfc0 --- /dev/null +++ b/backend/app/schemas/task_comment.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict +from datetime import datetime + + +class TaskCommentCreate(BaseModel): + content: str + + +class TaskCommentResponse(BaseModel): + id: int + task_id: int + content: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/frontend/package.json b/frontend/package.json index 75b1948..e9be121 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,7 +23,10 @@ "sonner": "^1.7.1", "clsx": "^2.1.1", "tailwind-merge": "^2.6.0", - "class-variance-authority": "^0.7.1" + "class-variance-authority": "^0.7.1", + "@dnd-kit/core": "^6.1.0", + "@dnd-kit/sortable": "^8.0.0", + "@dnd-kit/utilities": "^3.2.2" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx new file mode 100644 index 0000000..fa74af9 --- /dev/null +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -0,0 +1,188 @@ +import { + DndContext, + closestCorners, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, + useDroppable, + useDraggable, +} from '@dnd-kit/core'; +import { format, parseISO } from 'date-fns'; +import type { ProjectTask } from '@/types'; +import { Badge } from '@/components/ui/badge'; + +const COLUMNS: { id: string; label: string; color: string }[] = [ + { id: 'pending', label: 'Pending', color: 'text-gray-400' }, + { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, + { id: 'completed', label: 'Completed', color: 'text-green-400' }, +]; + +const priorityColors: Record = { + low: 'bg-green-500/20 text-green-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-red-500/20 text-red-400', +}; + +interface KanbanBoardProps { + tasks: ProjectTask[]; + selectedTaskId: number | null; + onSelectTask: (taskId: number) => void; + onStatusChange: (taskId: number, status: string) => void; +} + +function KanbanColumn({ + column, + tasks, + selectedTaskId, + onSelectTask, +}: { + column: (typeof COLUMNS)[0]; + tasks: ProjectTask[]; + selectedTaskId: number | null; + onSelectTask: (taskId: number) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: column.id }); + + return ( +
+ {/* Column header */} +
+
+ + {column.label} + + + {tasks.length} + +
+
+ + {/* Cards */} +
+ {tasks.map((task) => ( + onSelectTask(task.id)} + /> + ))} +
+
+ ); +} + +function KanbanCard({ + task, + isSelected, + onSelect, +}: { + task: ProjectTask; + isSelected: boolean; + onSelect: () => void; +}) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: task.id, + data: { task }, + }); + + const style = transform + ? { + transform: `translate(${transform.x}px, ${transform.y}px)`, + opacity: isDragging ? 0.5 : 1, + } + : undefined; + + const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; + const totalSubtasks = task.subtasks?.length ?? 0; + + return ( +
+

{task.title}

+
+ + {task.priority} + + {task.due_date && ( + + {format(parseISO(task.due_date), 'MMM d')} + + )} + {totalSubtasks > 0 && ( + + {completedSubtasks}/{totalSubtasks} + + )} +
+
+ ); +} + +export default function KanbanBoard({ + tasks, + selectedTaskId, + onSelectTask, + onStatusChange, +}: KanbanBoardProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over) return; + + const taskId = active.id as number; + const newStatus = over.id as string; + + // Only change if dropped on a different column + const task = tasks.find((t) => t.id === taskId); + if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { + onStatusChange(taskId, newStatus); + } + }; + + const tasksByStatus = COLUMNS.map((col) => ({ + column: col, + tasks: tasks.filter((t) => t.status === col.id), + })); + + return ( + +
+ {tasksByStatus.map(({ column, tasks: colTasks }) => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 32696b2..f7f5989 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -1,20 +1,40 @@ -import { useState } from 'react'; +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 { - ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil, + 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, 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 { Checkbox } from '@/components/ui/checkbox'; +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'; @@ -30,34 +50,80 @@ const statusLabels: Record = { completed: 'Completed', }; -const taskStatusColors: Record = { - pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', - in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20', - completed: 'bg-green-500/10 text-green-400 border-green-500/20', -}; +type SortMode = 'manual' | 'priority' | 'due_date'; +type ViewMode = 'list' | 'kanban'; -const priorityColors: Record = { - low: 'bg-green-500/20 text-green-400', - medium: 'bg-yellow-500/20 text-yellow-400', - high: 'bg-red-500/20 text-red-400', -}; +const PRIORITY_ORDER: Record = { high: 0, medium: 1, low: 2 }; -function getSubtaskProgress(task: ProjectTask) { - if (!task.subtasks || task.subtasks.length === 0) return null; - const completed = task.subtasks.filter((s) => s.status === 'completed').length; - const total = task.subtasks.length; - return { completed, total, percent: (completed / total) * 100 }; +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 [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) => { @@ -85,6 +151,9 @@ export default function ProjectDetail() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); }, + onError: () => { + toast.error('Failed to update task'); + }, }); const deleteTaskMutation = useMutation({ @@ -94,6 +163,7 @@ export default function ProjectDetail() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['projects', id] }); toast.success('Task deleted'); + setSelectedTaskId(null); }, }); @@ -111,6 +181,114 @@ export default function ProjectDetail() { }, }); + 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 sortedTasks = useMemo(() => { + const tasks = [...topLevelTasks]; + switch (sortMode) { + case 'priority': + return tasks.sort( + (a, b) => (PRIORITY_ORDER[a.priority] ?? 1) - (PRIORITY_ORDER[b.priority] ?? 1) + ); + 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]); + + 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 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 (
@@ -131,8 +309,6 @@ export default function ProjectDetail() { return
Project not found
; } - const allTasks = project.tasks || []; - const topLevelTasks = allTasks.filter((t) => !t.parent_task_id); const completedTasks = allTasks.filter((t) => t.status === 'completed').length; const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length; const overdueTasks = allTasks.filter( @@ -143,18 +319,6 @@ export default function ProjectDetail() { const isProjectOverdue = project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date)); - const openTaskForm = (task: ProjectTask | null, parentId: number | null) => { - setEditingTask(task); - setSubtaskParentId(parentId); - setShowTaskForm(true); - }; - - const closeTaskForm = () => { - setShowTaskForm(false); - setEditingTask(null); - setSubtaskParentId(null); - }; - return (
{/* Header */} @@ -187,277 +351,264 @@ export default function ProjectDetail() {
-
- {/* Description */} - {project.description && ( -

{project.description}

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

{project.description}

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

- {completedTasks} of {totalTasks} tasks completed -

-
- - {/* Divider */} -
- - {/* Mini stats */} -
-
-
- + {/* Project Summary Card */} + + +
+
+
+ Overall Progress + + {Math.round(progressPercent)}% +
-

{totalTasks}

-

Total

-
-
-
- -
-

{inProgressTasks}

-

Active

-
-
-
- -
-

{completedTasks}

-

Done

-
- {overdueTasks > 0 && ( -
-
- -
-

{overdueTasks}

-

Overdue

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

Tasks

- -
- - {/* Task list */} - {topLevelTasks.length === 0 ? ( - openTaskForm(null, null)} - /> - ) : ( -
- {topLevelTasks.map((task) => { - const progress = getSubtaskProgress(task); - const hasSubtasks = task.subtasks && task.subtasks.length > 0; - const isExpanded = expandedTasks.has(task.id); - - return ( -
-
- {/* Expand/collapse chevron */} - - - - toggleTaskMutation.mutate({ taskId: task.id, status: task.status }) - } - disabled={toggleTaskMutation.isPending} - className="mt-0.5" +
+
- -
-

- {task.title} -

- {task.description && ( -

{task.description}

- )} -
- - {task.status.replace('_', ' ')} - - - {task.priority} - - {task.due_date && ( - - {format(parseISO(task.due_date), 'MMM d')} - - )} -
- - {/* Subtask progress bar */} - {progress && ( -
-
- Subtasks - - {progress.completed}/{progress.total} - -
-
-
-
-
- )} -
- - {/* Actions */} -
- - - -
- - {/* Subtasks - shown when expanded */} - {isExpanded && hasSubtasks && ( -
- {task.subtasks.map((subtask) => ( -
- - toggleTaskMutation.mutate({ - taskId: subtask.id, - status: subtask.status, - }) - } - disabled={toggleTaskMutation.isPending} - className="mt-0.5" - /> -
-

- {subtask.title} -

- {subtask.description && ( -

{subtask.description}

- )} -
- - {subtask.status.replace('_', ' ')} - - - {subtask.priority} - -
-
-
- - -
-
- ))} +

+ {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' ? ( + setSelectedTaskId(taskId)} + onStatusChange={(taskId, status) => + updateTaskStatusMutation.mutate({ taskId, status }) + } + /> + ) : ( + + 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) */} + {selectedTaskId && ( +
+
+ openTaskForm(task, null)} + onDelete={handleDeleteTask} + onAddSubtask={(parentId) => openTaskForm(null, parentId)} + /> +
+
+ )} +
+ {/* Mobile: show detail panel as overlay when task selected on small screens */} + {selectedTaskId && selectedTask && ( +
+
+
+ Task Details + +
+
+ openTaskForm(task, null)} + onDelete={handleDeleteTask} + onAddSubtask={(parentId) => openTaskForm(null, parentId)} + /> +
+
+
+ )} + {showTaskForm && ( = { + pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20', + in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + completed: 'bg-green-500/10 text-green-400 border-green-500/20', +}; + +const taskStatusLabels: Record = { + pending: 'Pending', + in_progress: 'In Progress', + completed: 'Completed', +}; + +const priorityColors: Record = { + low: 'bg-green-500/20 text-green-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-red-500/20 text-red-400', +}; + +interface TaskDetailPanelProps { + task: ProjectTask | null; + projectId: number; + onEdit: (task: ProjectTask) => void; + onDelete: (taskId: number) => void; + onAddSubtask: (parentId: number) => void; +} + +export default function TaskDetailPanel({ + task, + projectId, + onEdit, + onDelete, + onAddSubtask, +}: TaskDetailPanelProps) { + const queryClient = useQueryClient(); + const [commentText, setCommentText] = useState(''); + + const { data: people = [] } = useQuery({ + queryKey: ['people'], + queryFn: async () => { + const { data } = await api.get('/people'); + return data; + }, + }); + + const toggleSubtaskMutation = useMutation({ + mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => { + const newStatus = status === 'completed' ? 'pending' : 'completed'; + const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, { + status: newStatus, + }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + }, + }); + + const addCommentMutation = useMutation({ + mutationFn: async (content: string) => { + const { data } = await api.post( + `/projects/${projectId}/tasks/${task!.id}/comments`, + { content } + ); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + setCommentText(''); + }, + onError: (error) => { + toast.error(getErrorMessage(error, 'Failed to add comment')); + }, + }); + + const deleteCommentMutation = useMutation({ + mutationFn: async (commentId: number) => { + await api.delete(`/projects/${projectId}/tasks/${task!.id}/comments/${commentId}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] }); + toast.success('Comment deleted'); + }, + }); + + const handleAddComment = () => { + const trimmed = commentText.trim(); + if (!trimmed) return; + addCommentMutation.mutate(trimmed); + }; + + if (!task) { + return ( +
+ +

Select a task to view details

+
+ ); + } + + const assignedPerson = task.person_id + ? people.find((p) => p.id === task.person_id) + : null; + + const comments = task.comments || []; + + return ( +
+ {/* Header */} +
+
+

+ {task.title} +

+
+ + +
+
+
+ + {/* Scrollable content */} +
+ {/* Fields grid */} +
+
+
+ + Status +
+ + {taskStatusLabels[task.status]} + +
+ +
+
+ + Priority +
+ + {task.priority} + +
+ +
+
+ + Due Date +
+

+ {task.due_date + ? format(parseISO(task.due_date), 'MMM d, yyyy') + : '—'} +

+
+ +
+
+ + Assigned +
+

+ {assignedPerson ? assignedPerson.name : '—'} +

+
+
+ + {/* Description */} + {task.description && ( +
+

+ Description +

+

+ {task.description} +

+
+ )} + + {/* Subtasks */} +
+
+

+ Subtasks + {task.subtasks.length > 0 && ( + + ({task.subtasks.filter((s) => s.status === 'completed').length}/ + {task.subtasks.length}) + + )} +

+ +
+ {task.subtasks.length > 0 ? ( +
+ {task.subtasks.map((subtask) => ( +
+ + toggleSubtaskMutation.mutate({ + taskId: subtask.id, + status: subtask.status, + }) + } + disabled={toggleSubtaskMutation.isPending} + /> + + {subtask.title} + + + {subtask.priority} + +
+ ))} +
+ ) : ( +

No subtasks yet

+ )} +
+ + {/* Comments */} +
+
+ +

+ Comments + {comments.length > 0 && ( + ({comments.length}) + )} +

+
+ + {/* Comment list */} + {comments.length > 0 && ( +
+ {comments.map((comment) => ( +
+

{comment.content}

+
+ + {formatDistanceToNow(parseISO(comment.created_at), { + addSuffix: true, + })} + + +
+
+ ))} +
+ )} + + {/* Add comment */} +
+