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:
parent
47baf2529c
commit
ccfbf6df96
37
backend/alembic/versions/002_add_subtasks.py
Normal file
37
backend/alembic/versions/002_add_subtasks.py
Normal 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')
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<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({
|
||||
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>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b bg-card px-6 py-4">
|
||||
@ -128,69 +164,178 @@ export default function ProjectDetail() {
|
||||
{project.description && (
|
||||
<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" />
|
||||
Add Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{!project.tasks || project.tasks.length === 0 ? (
|
||||
{topLevelTasks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ListChecks}
|
||||
title="No tasks yet"
|
||||
description="Break this project down into tasks to track your progress."
|
||||
actionLabel="Add Task"
|
||||
onAction={() => setShowTaskForm(true)}
|
||||
onAction={() => openTaskForm(null, null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{project.tasks.map((task) => (
|
||||
<Card key={task.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={task.status === 'completed'}
|
||||
onChange={() =>
|
||||
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
|
||||
}
|
||||
disabled={toggleTaskMutation.isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
||||
{task.description && (
|
||||
<CardDescription className="mt-1">{task.description}</CardDescription>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge className={taskStatusColors[task.status]}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
|
||||
{topLevelTasks.map((task) => {
|
||||
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>
|
||||
<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
|
||||
checked={task.status === 'completed'}
|
||||
onChange={() =>
|
||||
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
|
||||
}
|
||||
disabled={toggleTaskMutation.isPending}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
||||
{task.description && (
|
||||
<CardDescription className="mt-1">{task.description}</CardDescription>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge className={taskStatusColors[task.status]}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
|
||||
</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 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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openTaskForm(null, task.id)}
|
||||
title="Add subtask"
|
||||
>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteTaskMutation.mutate(task.id)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setEditingTask(task);
|
||||
setShowTaskForm(true);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteTaskMutation.mutate(task.id)}
|
||||
disabled={deleteTaskMutation.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -199,10 +344,8 @@ export default function ProjectDetail() {
|
||||
<TaskForm
|
||||
projectId={parseInt(id!)}
|
||||
task={editingTask}
|
||||
onClose={() => {
|
||||
setShowTaskForm(false);
|
||||
setEditingTask(null);
|
||||
}}
|
||||
parentTaskId={subtaskParentId}
|
||||
onClose={closeTaskForm}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -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<string, unknown> = {
|
||||
...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) {
|
||||
<DialogContent>
|
||||
<DialogClose onClick={onClose} />
|
||||
<DialogHeader>
|
||||
<DialogTitle>{task ? 'Edit Task' : 'New Task'}</DialogTitle>
|
||||
<DialogTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user