diff --git a/backend/alembic/versions/002_add_subtasks.py b/backend/alembic/versions/002_add_subtasks.py new file mode 100644 index 0000000..89ccf3b --- /dev/null +++ b/backend/alembic/versions/002_add_subtasks.py @@ -0,0 +1,37 @@ +"""Add subtask support with parent_task_id + +Revision ID: 002 +Revises: 001 +Create Date: 2026-02-15 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '002' +down_revision: Union[str, None] = '001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('project_tasks', sa.Column('parent_task_id', sa.Integer(), nullable=True)) + op.create_foreign_key( + 'fk_project_tasks_parent_task_id', + 'project_tasks', + 'project_tasks', + ['parent_task_id'], + ['id'], + ondelete='CASCADE' + ) + op.create_index('ix_project_tasks_parent_task_id', 'project_tasks', ['parent_task_id']) + + +def downgrade() -> None: + op.drop_index('ix_project_tasks_parent_task_id', table_name='project_tasks') + op.drop_constraint('fk_project_tasks_parent_task_id', 'project_tasks', type_='foreignkey') + op.drop_column('project_tasks', 'parent_task_id') diff --git a/backend/app/models/project_task.py b/backend/app/models/project_task.py index c6eed3c..ea08384 100644 --- a/backend/app/models/project_task.py +++ b/backend/app/models/project_task.py @@ -1,7 +1,7 @@ from sqlalchemy import String, Text, Integer, Date, ForeignKey, func -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship from datetime import datetime, date -from typing import Optional +from typing import Optional, List from app.database import Base @@ -10,6 +10,9 @@ class ProjectTask(Base): id: Mapped[int] = mapped_column(primary_key=True, index=True) project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False) + parent_task_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=True + ) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) status: Mapped[str] = mapped_column(String(20), default="pending") @@ -21,5 +24,13 @@ class ProjectTask(Base): updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now()) # Relationships - project: Mapped["Project"] = relationship(back_populates="tasks") - person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_tasks") + project: Mapped["Project"] = sa_relationship(back_populates="tasks") + person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks") + parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship( + back_populates="subtasks", + remote_side=[id], + ) + subtasks: Mapped[List["ProjectTask"]] = sa_relationship( + back_populates="parent_task", + cascade="all, delete-orphan", + ) diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 3d48c47..1cc4c53 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -15,13 +15,18 @@ 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) + + @router.get("/", response_model=List[ProjectResponse]) async def get_projects( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): """Get all projects with their tasks.""" - query = select(Project).options(selectinload(Project.tasks)).order_by(Project.created_at.desc()) + query = select(Project).options(_project_with_tasks()).order_by(Project.created_at.desc()) result = await db.execute(query) projects = result.scalars().all() @@ -40,7 +45,7 @@ async def create_project( await db.commit() # Re-fetch with eagerly loaded tasks for response serialization - query = select(Project).options(selectinload(Project.tasks)).where(Project.id == new_project.id) + query = select(Project).options(_project_with_tasks()).where(Project.id == new_project.id) result = await db.execute(query) return result.scalar_one() @@ -52,7 +57,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(selectinload(Project.tasks)).where(Project.id == project_id) + query = select(Project).options(_project_with_tasks()).where(Project.id == project_id) result = await db.execute(query) project = result.scalar_one_or_none() @@ -84,7 +89,7 @@ async def update_project( await db.commit() # Re-fetch with eagerly loaded tasks for response serialization - query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id) + query = select(Project).options(_project_with_tasks()).where(Project.id == project_id) result = await db.execute(query) return result.scalar_one() @@ -114,14 +119,22 @@ async def get_project_tasks( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Get all tasks for a specific project.""" + """Get top-level tasks for a specific project (subtasks are nested).""" 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") - query = select(ProjectTask).where(ProjectTask.project_id == project_id).order_by(ProjectTask.sort_order.asc()) + query = ( + select(ProjectTask) + .options(selectinload(ProjectTask.subtasks)) + .where( + ProjectTask.project_id == project_id, + ProjectTask.parent_task_id.is_(None), + ) + .order_by(ProjectTask.sort_order.asc()) + ) result = await db.execute(query) tasks = result.scalars().all() @@ -135,21 +148,43 @@ async def create_project_task( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Create a new task for a project.""" + """Create a new task or subtask for a project.""" 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") + # Validate parent_task_id if creating a subtask + if task.parent_task_id is not None: + parent_result = await db.execute( + select(ProjectTask).where( + ProjectTask.id == task.parent_task_id, + ProjectTask.project_id == project_id, + ProjectTask.parent_task_id.is_(None), # Parent must be top-level + ) + ) + parent_task = parent_result.scalar_one_or_none() + if not parent_task: + raise HTTPException( + status_code=400, + detail="Parent task not found or is itself a subtask", + ) + task_data = task.model_dump() task_data["project_id"] = project_id new_task = ProjectTask(**task_data) db.add(new_task) await db.commit() - await db.refresh(new_task) - return new_task + # Re-fetch with subtasks loaded + query = ( + select(ProjectTask) + .options(selectinload(ProjectTask.subtasks)) + .where(ProjectTask.id == new_task.id) + ) + result = await db.execute(query) + return result.scalar_one() @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse) @@ -178,9 +213,15 @@ async def update_project_task( setattr(task, key, value) await db.commit() - await db.refresh(task) - return task + # Re-fetch with subtasks loaded + query = ( + select(ProjectTask) + .options(selectinload(ProjectTask.subtasks)) + .where(ProjectTask.id == task_id) + ) + result = await db.execute(query) + return result.scalar_one() @router.delete("/{project_id}/tasks/{task_id}", status_code=204) @@ -190,7 +231,7 @@ async def delete_project_task( db: AsyncSession = Depends(get_db), current_user: Settings = Depends(get_current_session) ): - """Delete a project task.""" + """Delete a project task (cascades to subtasks).""" result = await db.execute( select(ProjectTask).where( ProjectTask.id == task_id, diff --git a/backend/app/schemas/project_task.py b/backend/app/schemas/project_task.py index 4a494db..50737e2 100644 --- a/backend/app/schemas/project_task.py +++ b/backend/app/schemas/project_task.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, ConfigDict from datetime import datetime, date -from typing import Optional +from typing import Optional, List class ProjectTaskCreate(BaseModel): @@ -11,6 +11,7 @@ class ProjectTaskCreate(BaseModel): due_date: Optional[date] = None person_id: Optional[int] = None sort_order: int = 0 + parent_task_id: Optional[int] = None class ProjectTaskUpdate(BaseModel): @@ -26,6 +27,7 @@ class ProjectTaskUpdate(BaseModel): class ProjectTaskResponse(BaseModel): id: int project_id: int + parent_task_id: Optional[int] = None title: str description: Optional[str] status: str @@ -35,5 +37,9 @@ class ProjectTaskResponse(BaseModel): sort_order: int created_at: datetime updated_at: datetime + subtasks: List["ProjectTaskResponse"] = [] model_config = ConfigDict(from_attributes=True) + + +ProjectTaskResponse.model_rebuild() diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index b24224f..9f0c45e 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { ArrowLeft, Plus, Trash2, ListChecks } from 'lucide-react'; +import { ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil } from 'lucide-react'; import api from '@/lib/api'; import type { Project, ProjectTask } from '@/types'; import { Button } from '@/components/ui/button'; @@ -20,18 +20,25 @@ const statusColors = { completed: 'bg-green-500/10 text-green-500 border-green-500/20', }; -const taskStatusColors = { +const taskStatusColors: Record = { pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20', in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20', completed: 'bg-green-500/10 text-green-500 border-green-500/20', }; -const priorityColors = { +const priorityColors: Record = { low: 'bg-green-500/10 text-green-500 border-green-500/20', medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20', high: 'bg-red-500/10 text-red-500 border-red-500/20', }; +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 }; +} + export default function ProjectDetail() { const { id } = useParams(); const navigate = useNavigate(); @@ -39,6 +46,20 @@ export default function ProjectDetail() { 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 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], @@ -105,6 +126,21 @@ export default function ProjectDetail() { return
Project not found
; } + // Filter to top-level tasks only (subtasks are nested inside their parent) + const topLevelTasks = project.tasks?.filter((t) => !t.parent_task_id) || []; + + const openTaskForm = (task: ProjectTask | null, parentId: number | null) => { + setEditingTask(task); + setSubtaskParentId(parentId); + setShowTaskForm(true); + }; + + const closeTaskForm = () => { + setShowTaskForm(false); + setEditingTask(null); + setSubtaskParentId(null); + }; + return (
@@ -128,69 +164,178 @@ export default function ProjectDetail() { {project.description && (

{project.description}

)} -
- {!project.tasks || project.tasks.length === 0 ? ( + {topLevelTasks.length === 0 ? ( setShowTaskForm(true)} + onAction={() => openTaskForm(null, null)} /> ) : (
- {project.tasks.map((task) => ( - - -
- - toggleTaskMutation.mutate({ taskId: task.id, status: task.status }) - } - disabled={toggleTaskMutation.isPending} - className="mt-1" - /> -
- {task.title} - {task.description && ( - {task.description} - )} -
- - {task.status.replace('_', ' ')} - - {task.priority} + {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-1" + /> +
+ {task.title} + {task.description && ( + {task.description} + )} +
+ + {task.status.replace('_', ' ')} + + {task.priority} +
+ + {/* Subtask progress bar */} + {progress && ( +
+
+ Subtasks + + {progress.completed}/{progress.total} + +
+
+
+
+
+ )} +
+ + {/* Add subtask */} + + +
+ + + + {/* 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} + +
+
+ + +
+
+
+ ))}
- - -
-
-
- ))} + )} +
+ ); + })}
)}
@@ -199,10 +344,8 @@ export default function ProjectDetail() { { - setShowTaskForm(false); - setEditingTask(null); - }} + parentTaskId={subtaskParentId} + onClose={closeTaskForm} /> )} diff --git a/frontend/src/components/projects/TaskForm.tsx b/frontend/src/components/projects/TaskForm.tsx index 9220117..4d8b7dc 100644 --- a/frontend/src/components/projects/TaskForm.tsx +++ b/frontend/src/components/projects/TaskForm.tsx @@ -20,10 +20,11 @@ import { Button } from '@/components/ui/button'; interface TaskFormProps { projectId: number; task: ProjectTask | null; + parentTaskId?: number | null; onClose: () => void; } -export default function TaskForm({ projectId, task, onClose }: TaskFormProps) { +export default function TaskForm({ projectId, task, parentTaskId, onClose }: TaskFormProps) { const queryClient = useQueryClient(); const [formData, setFormData] = useState({ title: task?.title || '', @@ -44,7 +45,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) { const mutation = useMutation({ mutationFn: async (data: typeof formData) => { - const payload = { + const payload: Record = { ...data, person_id: data.person_id ? parseInt(data.person_id) : null, }; @@ -52,6 +53,9 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) { const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload); return response.data; } else { + if (parentTaskId) { + payload.parent_task_id = parentTaskId; + } const response = await api.post(`/projects/${projectId}/tasks`, payload); return response.data; } @@ -76,7 +80,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) { - {task ? 'Edit Task' : 'New Task'} + {task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7daed90..7ab4af4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -62,6 +62,7 @@ export interface Project { export interface ProjectTask { id: number; project_id: number; + parent_task_id?: number | null; title: string; description?: string; status: 'pending' | 'in_progress' | 'completed'; @@ -71,6 +72,7 @@ export interface ProjectTask { sort_order: number; created_at: string; updated_at: string; + subtasks: ProjectTask[]; } export interface Person {