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 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",
|
||||||
|
)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,69 +164,178 @@ 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);
|
||||||
<CardHeader>
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||||
<div className="flex items-start gap-3">
|
const isExpanded = expandedTasks.has(task.id);
|
||||||
<Checkbox
|
|
||||||
checked={task.status === 'completed'}
|
return (
|
||||||
onChange={() =>
|
<div key={task.id}>
|
||||||
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
|
<Card>
|
||||||
}
|
<CardHeader>
|
||||||
disabled={toggleTaskMutation.isPending}
|
<div className="flex items-start gap-3">
|
||||||
className="mt-1"
|
{/* Expand/collapse chevron */}
|
||||||
/>
|
<button
|
||||||
<div className="flex-1 min-w-0">
|
onClick={() => hasSubtasks && toggleExpand(task.id)}
|
||||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
className={`mt-1 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
|
||||||
{task.description && (
|
>
|
||||||
<CardDescription className="mt-1">{task.description}</CardDescription>
|
<ChevronRight
|
||||||
)}
|
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
||||||
<div className="flex items-center gap-2 mt-2">
|
/>
|
||||||
<Badge className={taskStatusColors[task.status]}>
|
</button>
|
||||||
{task.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
<Checkbox
|
||||||
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
|
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>
|
</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>
|
</div>
|
||||||
<Button
|
)}
|
||||||
variant="ghost"
|
</div>
|
||||||
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>
|
</div>
|
||||||
@ -199,10 +344,8 @@ export default function ProjectDetail() {
|
|||||||
<TaskForm
|
<TaskForm
|
||||||
projectId={parseInt(id!)}
|
projectId={parseInt(id!)}
|
||||||
task={editingTask}
|
task={editingTask}
|
||||||
onClose={() => {
|
parentTaskId={subtaskParentId}
|
||||||
setShowTaskForm(false);
|
onClose={closeTaskForm}
|
||||||
setEditingTask(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user