Add subtasks feature to project tasks

Backend:
- Add self-referencing parent_task_id FK on project_tasks with CASCADE delete
- Add Alembic migration 002 for parent_task_id column + index
- Update schemas with parent_task_id in create, nested subtasks in response
- Chain selectinload for subtasks on all project queries
- Validate parent must be top-level task (single nesting level only)

Frontend:
- Add parent_task_id and subtasks[] to ProjectTask type
- ProjectDetail: expand/collapse chevrons, subtask progress bars, inline
  subtask rendering with accent left border, add/edit/delete subtask buttons
- TaskForm: accept parentTaskId prop, include in create payload, context-aware
  dialog title (New Task vs New Subtask)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-16 01:31:46 +08:00
parent 47baf2529c
commit ccfbf6df96
7 changed files with 318 additions and 74 deletions

View File

@ -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')

View File

@ -1,7 +1,7 @@
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func 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 datetime import datetime, date
from typing import Optional from typing import Optional, List
from app.database import Base from app.database import Base
@ -10,6 +10,9 @@ class ProjectTask(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True) id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False) 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) title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="pending") 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()) updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships # Relationships
project: Mapped["Project"] = relationship(back_populates="tasks") project: Mapped["Project"] = sa_relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_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",
)

View File

@ -15,13 +15,18 @@ from app.models.settings import Settings
router = APIRouter() 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]) @router.get("/", response_model=List[ProjectResponse])
async def get_projects( async def get_projects(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get all projects with their tasks.""" """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) result = await db.execute(query)
projects = result.scalars().all() projects = result.scalars().all()
@ -40,7 +45,7 @@ async def create_project(
await db.commit() await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization # 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) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -52,7 +57,7 @@ async def get_project(
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Get a specific project by ID with its tasks.""" """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) result = await db.execute(query)
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
@ -84,7 +89,7 @@ async def update_project(
await db.commit() await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization # 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) result = await db.execute(query)
return result.scalar_one() return result.scalar_one()
@ -114,14 +119,22 @@ async def get_project_tasks(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) 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)) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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) result = await db.execute(query)
tasks = result.scalars().all() tasks = result.scalars().all()
@ -135,21 +148,43 @@ async def create_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) 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)) result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none() project = result.scalar_one_or_none()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") 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 = task.model_dump()
task_data["project_id"] = project_id task_data["project_id"] = project_id
new_task = ProjectTask(**task_data) new_task = ProjectTask(**task_data)
db.add(new_task) db.add(new_task)
await db.commit() 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) @router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
@ -178,9 +213,15 @@ async def update_project_task(
setattr(task, key, value) setattr(task, key, value)
await db.commit() 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) @router.delete("/{project_id}/tasks/{task_id}", status_code=204)
@ -190,7 +231,7 @@ async def delete_project_task(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session) current_user: Settings = Depends(get_current_session)
): ):
"""Delete a project task.""" """Delete a project task (cascades to subtasks)."""
result = await db.execute( result = await db.execute(
select(ProjectTask).where( select(ProjectTask).where(
ProjectTask.id == task_id, ProjectTask.id == task_id,

View File

@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from datetime import datetime, date from datetime import datetime, date
from typing import Optional from typing import Optional, List
class ProjectTaskCreate(BaseModel): class ProjectTaskCreate(BaseModel):
@ -11,6 +11,7 @@ class ProjectTaskCreate(BaseModel):
due_date: Optional[date] = None due_date: Optional[date] = None
person_id: Optional[int] = None person_id: Optional[int] = None
sort_order: int = 0 sort_order: int = 0
parent_task_id: Optional[int] = None
class ProjectTaskUpdate(BaseModel): class ProjectTaskUpdate(BaseModel):
@ -26,6 +27,7 @@ class ProjectTaskUpdate(BaseModel):
class ProjectTaskResponse(BaseModel): class ProjectTaskResponse(BaseModel):
id: int id: int
project_id: int project_id: int
parent_task_id: Optional[int] = None
title: str title: str
description: Optional[str] description: Optional[str]
status: str status: str
@ -35,5 +37,9 @@ class ProjectTaskResponse(BaseModel):
sort_order: int sort_order: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
subtasks: List["ProjectTaskResponse"] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
ProjectTaskResponse.model_rebuild()

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; 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 api from '@/lib/api';
import type { Project, ProjectTask } from '@/types'; import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -20,18 +20,25 @@ const statusColors = {
completed: 'bg-green-500/10 text-green-500 border-green-500/20', completed: 'bg-green-500/10 text-green-500 border-green-500/20',
}; };
const taskStatusColors = { const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20', 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', 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', completed: 'bg-green-500/10 text-green-500 border-green-500/20',
}; };
const priorityColors = { const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-500 border-green-500/20', low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-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', 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() { export default function ProjectDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@ -39,6 +46,20 @@ export default function ProjectDetail() {
const [showTaskForm, setShowTaskForm] = useState(false); const [showTaskForm, setShowTaskForm] = useState(false);
const [showProjectForm, setShowProjectForm] = useState(false); const [showProjectForm, setShowProjectForm] = useState(false);
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null); const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(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({ const { data: project, isLoading } = useQuery({
queryKey: ['projects', id], queryKey: ['projects', id],
@ -105,6 +126,21 @@ export default function ProjectDetail() {
return <div className="p-6 text-center text-muted-foreground">Project not found</div>; return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
} }
// 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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4"> <div className="border-b bg-card px-6 py-4">
@ -128,27 +164,43 @@ export default function ProjectDetail() {
{project.description && ( {project.description && (
<p className="text-muted-foreground mb-4">{project.description}</p> <p className="text-muted-foreground mb-4">{project.description}</p>
)} )}
<Button onClick={() => setShowTaskForm(true)}> <Button onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Task Add Task
</Button> </Button>
</div> </div>
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{!project.tasks || project.tasks.length === 0 ? ( {topLevelTasks.length === 0 ? (
<EmptyState <EmptyState
icon={ListChecks} icon={ListChecks}
title="No tasks yet" title="No tasks yet"
description="Break this project down into tasks to track your progress." description="Break this project down into tasks to track your progress."
actionLabel="Add Task" actionLabel="Add Task"
onAction={() => setShowTaskForm(true)} onAction={() => openTaskForm(null, null)}
/> />
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{project.tasks.map((task) => ( {topLevelTasks.map((task) => {
<Card key={task.id}> const progress = getSubtaskProgress(task);
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
const isExpanded = expandedTasks.has(task.id);
return (
<div key={task.id}>
<Card>
<CardHeader> <CardHeader>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Expand/collapse chevron */}
<button
onClick={() => hasSubtasks && toggleExpand(task.id)}
className={`mt-1 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
>
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<Checkbox <Checkbox
checked={task.status === 'completed'} checked={task.status === 'completed'}
onChange={() => onChange={() =>
@ -168,41 +220,132 @@ export default function ProjectDetail() {
</Badge> </Badge>
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge> <Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
</div> </div>
{/* Subtask progress bar */}
{progress && (
<div className="mt-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Subtasks</span>
<span className="font-medium">
{progress.completed}/{progress.total}
</span>
</div> </div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
</div>
{/* Add subtask */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => { onClick={() => openTaskForm(null, task.id)}
setEditingTask(task); title="Add subtask"
setShowTaskForm(true);
}}
> >
Edit <Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(task, null)}
title="Edit task"
>
<Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => deleteTaskMutation.mutate(task.id)} onClick={() => deleteTaskMutation.mutate(task.id)}
disabled={deleteTaskMutation.isPending} disabled={deleteTaskMutation.isPending}
title="Delete task"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
{/* Subtasks - shown when expanded */}
{isExpanded && hasSubtasks && (
<div className="ml-9 mt-1 space-y-1">
{task.subtasks.map((subtask) => (
<Card key={subtask.id} className="border-l-2 border-accent/30">
<CardHeader className="py-3 px-4">
<div className="flex items-start gap-3">
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm font-medium">
{subtask.title}
</CardTitle>
{subtask.description && (
<CardDescription className="mt-0.5 text-xs">
{subtask.description}
</CardDescription>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge
className={`text-xs ${taskStatusColors[subtask.status]}`}
>
{subtask.status.replace('_', ' ')}
</Badge>
<Badge
className={`text-xs ${priorityColors[subtask.priority]}`}
>
{subtask.priority}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(subtask, task.id)}
title="Edit subtask"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(subtask.id)}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
))} ))}
</div> </div>
)} )}
</div> </div>
);
})}
</div>
)}
</div>
{showTaskForm && ( {showTaskForm && (
<TaskForm <TaskForm
projectId={parseInt(id!)} projectId={parseInt(id!)}
task={editingTask} task={editingTask}
onClose={() => { parentTaskId={subtaskParentId}
setShowTaskForm(false); onClose={closeTaskForm}
setEditingTask(null);
}}
/> />
)} )}

View File

@ -20,10 +20,11 @@ import { Button } from '@/components/ui/button';
interface TaskFormProps { interface TaskFormProps {
projectId: number; projectId: number;
task: ProjectTask | null; task: ProjectTask | null;
parentTaskId?: number | null;
onClose: () => void; onClose: () => void;
} }
export default function TaskForm({ projectId, task, onClose }: TaskFormProps) { export default function TaskForm({ projectId, task, parentTaskId, onClose }: TaskFormProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: task?.title || '', title: task?.title || '',
@ -44,7 +45,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (data: typeof formData) => { mutationFn: async (data: typeof formData) => {
const payload = { const payload: Record<string, unknown> = {
...data, ...data,
person_id: data.person_id ? parseInt(data.person_id) : null, 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); const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload);
return response.data; return response.data;
} else { } else {
if (parentTaskId) {
payload.parent_task_id = parentTaskId;
}
const response = await api.post(`/projects/${projectId}/tasks`, payload); const response = await api.post(`/projects/${projectId}/tasks`, payload);
return response.data; return response.data;
} }
@ -76,7 +80,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
<DialogContent> <DialogContent>
<DialogClose onClick={onClose} /> <DialogClose onClick={onClose} />
<DialogHeader> <DialogHeader>
<DialogTitle>{task ? 'Edit Task' : 'New Task'}</DialogTitle> <DialogTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -62,6 +62,7 @@ export interface Project {
export interface ProjectTask { export interface ProjectTask {
id: number; id: number;
project_id: number; project_id: number;
parent_task_id?: number | null;
title: string; title: string;
description?: string; description?: string;
status: 'pending' | 'in_progress' | 'completed'; status: 'pending' | 'in_progress' | 'completed';
@ -71,6 +72,7 @@ export interface ProjectTask {
sort_order: number; sort_order: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
subtasks: ProjectTask[];
} }
export interface Person { export interface Person {