ProjectDetail overhaul: master-detail layout, comments, sorting, kanban
- Backend: TaskComment model + migration, comment CRUD endpoints, task reorder endpoint, updated selectinload for comments - Frontend: Two-panel master-detail layout with TaskRow (compact) and TaskDetailPanel (full details + comments section) - Sort toolbar: manual (drag-and-drop via @dnd-kit), priority, due date - Kanban board view with drag-and-drop between status columns - Responsive: mobile falls back to overlay panel on task select Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3a7eb33809
commit
6e50089201
36
backend/alembic/versions/009_add_task_comments.py
Normal file
36
backend/alembic/versions/009_add_task_comments.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""add task comments
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
Create Date: 2026-02-22
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "009"
|
||||||
|
down_revision = "008"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"task_comments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||||
|
sa.Column(
|
||||||
|
"task_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("project_tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_task_comments_task_id", "task_comments", ["task_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_task_comments_task_id", table_name="task_comments")
|
||||||
|
op.drop_table("task_comments")
|
||||||
@ -7,6 +7,7 @@ from app.models.project import Project
|
|||||||
from app.models.project_task import ProjectTask
|
from app.models.project_task import ProjectTask
|
||||||
from app.models.person import Person
|
from app.models.person import Person
|
||||||
from app.models.location import Location
|
from app.models.location import Location
|
||||||
|
from app.models.task_comment import TaskComment
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Settings",
|
"Settings",
|
||||||
@ -18,4 +19,5 @@ __all__ = [
|
|||||||
"ProjectTask",
|
"ProjectTask",
|
||||||
"Person",
|
"Person",
|
||||||
"Location",
|
"Location",
|
||||||
|
"TaskComment",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -34,3 +34,7 @@ class ProjectTask(Base):
|
|||||||
back_populates="parent_task",
|
back_populates="parent_task",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
comments: Mapped[List["TaskComment"]] = sa_relationship(
|
||||||
|
back_populates="task",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|||||||
18
backend/app/models/task_comment.py
Normal file
18
backend/app/models/task_comment.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
from sqlalchemy import Text, Integer, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class TaskComment(Base):
|
||||||
|
__tablename__ = "task_comments"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||||
|
task_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
|
||||||
@ -3,21 +3,42 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from typing import List
|
from typing import List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.project import Project
|
from app.models.project import Project
|
||||||
from app.models.project_task import ProjectTask
|
from app.models.project_task import ProjectTask
|
||||||
|
from app.models.task_comment import TaskComment
|
||||||
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse
|
||||||
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
|
||||||
|
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
|
||||||
from app.routers.auth import get_current_session
|
from app.routers.auth import get_current_session
|
||||||
from app.models.settings import Settings
|
from app.models.settings import Settings
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
def _project_with_tasks():
|
class ReorderItem(BaseModel):
|
||||||
"""Eager load projects with tasks and their subtasks."""
|
id: int
|
||||||
return selectinload(Project.tasks).selectinload(ProjectTask.subtasks)
|
sort_order: int
|
||||||
|
|
||||||
|
|
||||||
|
def _project_load_options():
|
||||||
|
"""All load options needed for project responses (tasks + subtasks + comments at each level)."""
|
||||||
|
return [
|
||||||
|
selectinload(Project.tasks).selectinload(ProjectTask.comments),
|
||||||
|
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||||
|
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _task_load_options():
|
||||||
|
"""All load options needed for task responses."""
|
||||||
|
return [
|
||||||
|
selectinload(ProjectTask.comments),
|
||||||
|
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
|
||||||
|
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=List[ProjectResponse])
|
@router.get("/", response_model=List[ProjectResponse])
|
||||||
@ -26,9 +47,9 @@ async def get_projects(
|
|||||||
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(_project_with_tasks()).order_by(Project.created_at.desc())
|
query = select(Project).options(*_project_load_options()).order_by(Project.created_at.desc())
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
projects = result.scalars().all()
|
projects = result.scalars().unique().all()
|
||||||
|
|
||||||
return projects
|
return projects
|
||||||
|
|
||||||
@ -45,7 +66,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(_project_with_tasks()).where(Project.id == new_project.id)
|
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@ -57,7 +78,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(_project_with_tasks()).where(Project.id == project_id)
|
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
project = result.scalar_one_or_none()
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
@ -89,7 +110,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(_project_with_tasks()).where(Project.id == project_id)
|
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
@ -128,7 +149,7 @@ async def get_project_tasks(
|
|||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(ProjectTask)
|
select(ProjectTask)
|
||||||
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
|
.options(*_task_load_options())
|
||||||
.where(
|
.where(
|
||||||
ProjectTask.project_id == project_id,
|
ProjectTask.project_id == project_id,
|
||||||
ProjectTask.parent_task_id.is_(None),
|
ProjectTask.parent_task_id.is_(None),
|
||||||
@ -136,7 +157,7 @@ async def get_project_tasks(
|
|||||||
.order_by(ProjectTask.sort_order.asc())
|
.order_by(ProjectTask.sort_order.asc())
|
||||||
)
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
tasks = result.scalars().all()
|
tasks = result.scalars().unique().all()
|
||||||
|
|
||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
@ -180,13 +201,43 @@ async def create_project_task(
|
|||||||
# Re-fetch with subtasks loaded
|
# Re-fetch with subtasks loaded
|
||||||
query = (
|
query = (
|
||||||
select(ProjectTask)
|
select(ProjectTask)
|
||||||
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
|
.options(*_task_load_options())
|
||||||
.where(ProjectTask.id == new_task.id)
|
.where(ProjectTask.id == new_task.id)
|
||||||
)
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
return result.scalar_one()
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{project_id}/tasks/reorder", status_code=200)
|
||||||
|
async def reorder_tasks(
|
||||||
|
project_id: int,
|
||||||
|
items: List[ReorderItem],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Bulk update sort_order for tasks."""
|
||||||
|
result = await db.execute(select(Project).where(Project.id == project_id))
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
task_result = await db.execute(
|
||||||
|
select(ProjectTask).where(
|
||||||
|
ProjectTask.id == item.id,
|
||||||
|
ProjectTask.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = task_result.scalar_one_or_none()
|
||||||
|
if task:
|
||||||
|
task.sort_order = item.sort_order
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
|
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
|
||||||
async def update_project_task(
|
async def update_project_task(
|
||||||
project_id: int,
|
project_id: int,
|
||||||
@ -217,7 +268,7 @@ async def update_project_task(
|
|||||||
# Re-fetch with subtasks loaded
|
# Re-fetch with subtasks loaded
|
||||||
query = (
|
query = (
|
||||||
select(ProjectTask)
|
select(ProjectTask)
|
||||||
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
|
.options(*_task_load_options())
|
||||||
.where(ProjectTask.id == task_id)
|
.where(ProjectTask.id == task_id)
|
||||||
)
|
)
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@ -247,3 +298,57 @@ async def delete_project_task(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
|
||||||
|
async def create_task_comment(
|
||||||
|
project_id: int,
|
||||||
|
task_id: int,
|
||||||
|
comment: TaskCommentCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Add a comment to a task."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(ProjectTask).where(
|
||||||
|
ProjectTask.id == task_id,
|
||||||
|
ProjectTask.project_id == project_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
new_comment = TaskComment(task_id=task_id, content=comment.content)
|
||||||
|
db.add(new_comment)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_comment)
|
||||||
|
|
||||||
|
return new_comment
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
|
||||||
|
async def delete_task_comment(
|
||||||
|
project_id: int,
|
||||||
|
task_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: Settings = Depends(get_current_session)
|
||||||
|
):
|
||||||
|
"""Delete a task comment."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(TaskComment).where(
|
||||||
|
TaskComment.id == comment_id,
|
||||||
|
TaskComment.task_id == task_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
comment = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=404, detail="Comment not found")
|
||||||
|
|
||||||
|
await db.delete(comment)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Optional, List, Literal
|
from typing import Optional, List, Literal
|
||||||
|
from app.schemas.task_comment import TaskCommentResponse
|
||||||
|
|
||||||
TaskStatus = Literal["pending", "in_progress", "completed"]
|
TaskStatus = Literal["pending", "in_progress", "completed"]
|
||||||
TaskPriority = Literal["low", "medium", "high"]
|
TaskPriority = Literal["low", "medium", "high"]
|
||||||
@ -41,6 +42,7 @@ class ProjectTaskResponse(BaseModel):
|
|||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
subtasks: List["ProjectTaskResponse"] = []
|
subtasks: List["ProjectTaskResponse"] = []
|
||||||
|
comments: List[TaskCommentResponse] = []
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|||||||
15
backend/app/schemas/task_comment.py
Normal file
15
backend/app/schemas/task_comment.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCommentCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class TaskCommentResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
task_id: int
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
@ -23,7 +23,10 @@
|
|||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"class-variance-authority": "^0.7.1"
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"@dnd-kit/core": "^6.1.0",
|
||||||
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
|
|||||||
188
frontend/src/components/projects/KanbanBoard.tsx
Normal file
188
frontend/src/components/projects/KanbanBoard.tsx
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCorners,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
useDroppable,
|
||||||
|
useDraggable,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import type { ProjectTask } from '@/types';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||||
|
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||||
|
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||||
|
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-green-500/20 text-green-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
high: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KanbanBoardProps {
|
||||||
|
tasks: ProjectTask[];
|
||||||
|
selectedTaskId: number | null;
|
||||||
|
onSelectTask: (taskId: number) => void;
|
||||||
|
onStatusChange: (taskId: number, status: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanColumn({
|
||||||
|
column,
|
||||||
|
tasks,
|
||||||
|
selectedTaskId,
|
||||||
|
onSelectTask,
|
||||||
|
}: {
|
||||||
|
column: (typeof COLUMNS)[0];
|
||||||
|
tasks: ProjectTask[];
|
||||||
|
selectedTaskId: number | null;
|
||||||
|
onSelectTask: (taskId: number) => void;
|
||||||
|
}) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={`flex-1 min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||||
|
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Column header */}
|
||||||
|
<div className="px-3 py-2.5 border-b border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||||
|
{tasks.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cards */}
|
||||||
|
<div className="p-2 space-y-2 min-h-[100px]">
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<KanbanCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
isSelected={selectedTaskId === task.id}
|
||||||
|
onSelect={() => onSelectTask(task.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KanbanCard({
|
||||||
|
task,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
task: ProjectTask;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||||
|
id: task.id,
|
||||||
|
data: { task },
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = transform
|
||||||
|
? {
|
||||||
|
transform: `translate(${transform.x}px, ${transform.y}px)`,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||||
|
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
||||||
|
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Badge
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
{task.due_date && (
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{format(parseISO(task.due_date), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalSubtasks > 0 && (
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{completedSubtasks}/{totalSubtasks}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanBoard({
|
||||||
|
tasks,
|
||||||
|
selectedTaskId,
|
||||||
|
onSelectTask,
|
||||||
|
onStatusChange,
|
||||||
|
}: KanbanBoardProps) {
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const taskId = active.id as number;
|
||||||
|
const newStatus = over.id as string;
|
||||||
|
|
||||||
|
// Only change if dropped on a different column
|
||||||
|
const task = tasks.find((t) => t.id === taskId);
|
||||||
|
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||||
|
onStatusChange(taskId, newStatus);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tasksByStatus = COLUMNS.map((col) => ({
|
||||||
|
column: col,
|
||||||
|
tasks: tasks.filter((t) => t.status === col.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||||
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
column={column}
|
||||||
|
tasks={colTasks}
|
||||||
|
selectedTaskId={selectedTaskId}
|
||||||
|
onSelectTask={onSelectTask}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,20 +1,40 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo, useCallback } 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 { format, isPast, parseISO } from 'date-fns';
|
import { format, isPast, parseISO } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil,
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
useSortable,
|
||||||
|
arrayMove,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Plus, Trash2, ListChecks, Pencil,
|
||||||
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
Calendar, CheckCircle2, PlayCircle, AlertTriangle,
|
||||||
|
List, Columns3, ArrowUpDown,
|
||||||
} from 'lucide-react';
|
} 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';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Select } from '@/components/ui/select';
|
||||||
import { ListSkeleton } from '@/components/ui/skeleton';
|
import { ListSkeleton } from '@/components/ui/skeleton';
|
||||||
import { EmptyState } from '@/components/ui/empty-state';
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
import TaskRow from './TaskRow';
|
||||||
|
import TaskDetailPanel from './TaskDetailPanel';
|
||||||
|
import KanbanBoard from './KanbanBoard';
|
||||||
import TaskForm from './TaskForm';
|
import TaskForm from './TaskForm';
|
||||||
import ProjectForm from './ProjectForm';
|
import ProjectForm from './ProjectForm';
|
||||||
|
|
||||||
@ -30,34 +50,80 @@ const statusLabels: Record<string, string> = {
|
|||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskStatusColors: Record<string, string> = {
|
type SortMode = 'manual' | 'priority' | 'due_date';
|
||||||
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
type ViewMode = 'list' | 'kanban';
|
||||||
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
|
||||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 };
|
||||||
|
|
||||||
|
function SortableTaskRow({
|
||||||
|
task,
|
||||||
|
isSelected,
|
||||||
|
isExpanded,
|
||||||
|
showDragHandle,
|
||||||
|
onSelect,
|
||||||
|
onToggleExpand,
|
||||||
|
onToggleStatus,
|
||||||
|
togglePending,
|
||||||
|
}: {
|
||||||
|
task: ProjectTask;
|
||||||
|
isSelected: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
showDragHandle: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onToggleStatus: () => void;
|
||||||
|
togglePending: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: task.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
const priorityColors: Record<string, string> = {
|
return (
|
||||||
low: 'bg-green-500/20 text-green-400',
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
<TaskRow
|
||||||
high: 'bg-red-500/20 text-red-400',
|
task={task}
|
||||||
};
|
isSelected={isSelected}
|
||||||
|
isExpanded={isExpanded}
|
||||||
function getSubtaskProgress(task: ProjectTask) {
|
showDragHandle={showDragHandle}
|
||||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
onSelect={onSelect}
|
||||||
const completed = task.subtasks.filter((s) => s.status === 'completed').length;
|
onToggleExpand={onToggleExpand}
|
||||||
const total = task.subtasks.length;
|
onToggleStatus={onToggleStatus}
|
||||||
return { completed, total, percent: (completed / total) * 100 };
|
togglePending={togglePending}
|
||||||
|
dragHandleProps={listeners}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectDetail() {
|
export default function ProjectDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
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 [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
|
||||||
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
|
||||||
|
const [sortMode, setSortMode] = useState<SortMode>('manual');
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
);
|
||||||
|
|
||||||
const toggleExpand = (taskId: number) => {
|
const toggleExpand = (taskId: number) => {
|
||||||
setExpandedTasks((prev) => {
|
setExpandedTasks((prev) => {
|
||||||
@ -85,6 +151,9 @@ export default function ProjectDetail() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update task');
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteTaskMutation = useMutation({
|
const deleteTaskMutation = useMutation({
|
||||||
@ -94,6 +163,7 @@ export default function ProjectDetail() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
toast.success('Task deleted');
|
toast.success('Task deleted');
|
||||||
|
setSelectedTaskId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -111,6 +181,114 @@ export default function ProjectDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: async (items: { id: number; sort_order: number }[]) => {
|
||||||
|
await api.put(`/projects/${id}/tasks/reorder`, items);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskStatusMutation = useMutation({
|
||||||
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||||
|
const { data } = await api.put(`/projects/${id}/tasks/${taskId}`, { status });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', id] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update task status');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTasks = project?.tasks || [];
|
||||||
|
const topLevelTasks = useMemo(
|
||||||
|
() => allTasks.filter((t) => !t.parent_task_id),
|
||||||
|
[allTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedTasks = useMemo(() => {
|
||||||
|
const tasks = [...topLevelTasks];
|
||||||
|
switch (sortMode) {
|
||||||
|
case 'priority':
|
||||||
|
return tasks.sort(
|
||||||
|
(a, b) => (PRIORITY_ORDER[a.priority] ?? 1) - (PRIORITY_ORDER[b.priority] ?? 1)
|
||||||
|
);
|
||||||
|
case 'due_date':
|
||||||
|
return tasks.sort((a, b) => {
|
||||||
|
if (!a.due_date && !b.due_date) return 0;
|
||||||
|
if (!a.due_date) return 1;
|
||||||
|
if (!b.due_date) return -1;
|
||||||
|
return a.due_date.localeCompare(b.due_date);
|
||||||
|
});
|
||||||
|
case 'manual':
|
||||||
|
default:
|
||||||
|
return tasks.sort((a, b) => a.sort_order - b.sort_order);
|
||||||
|
}
|
||||||
|
}, [topLevelTasks, sortMode]);
|
||||||
|
|
||||||
|
const selectedTask = useMemo(() => {
|
||||||
|
if (!selectedTaskId) return null;
|
||||||
|
// Search top-level and subtasks
|
||||||
|
for (const task of allTasks) {
|
||||||
|
if (task.id === selectedTaskId) return task;
|
||||||
|
if (task.subtasks) {
|
||||||
|
const sub = task.subtasks.find((s) => s.id === selectedTaskId);
|
||||||
|
if (sub) return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [selectedTaskId, allTasks]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const oldIndex = sortedTasks.findIndex((t) => t.id === active.id);
|
||||||
|
const newIndex = sortedTasks.findIndex((t) => t.id === over.id);
|
||||||
|
const reordered = arrayMove(sortedTasks, oldIndex, newIndex);
|
||||||
|
|
||||||
|
const items = reordered.map((task, index) => ({
|
||||||
|
id: task.id,
|
||||||
|
sort_order: index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
queryClient.setQueryData(['projects', id], (old: Project | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
const updated = { ...old, tasks: [...old.tasks] };
|
||||||
|
for (const item of items) {
|
||||||
|
const t = updated.tasks.find((tt) => tt.id === item.id);
|
||||||
|
if (t) t.sort_order = item.sort_order;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
reorderMutation.mutate(items);
|
||||||
|
},
|
||||||
|
[sortedTasks, id, queryClient, reorderMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
|
||||||
|
setEditingTask(task);
|
||||||
|
setSubtaskParentId(parentId);
|
||||||
|
setShowTaskForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeTaskForm = () => {
|
||||||
|
setShowTaskForm(false);
|
||||||
|
setEditingTask(null);
|
||||||
|
setSubtaskParentId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTask = (taskId: number) => {
|
||||||
|
if (!window.confirm('Delete this task and all its subtasks?')) return;
|
||||||
|
deleteTaskMutation.mutate(taskId);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@ -131,8 +309,6 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allTasks = project.tasks || [];
|
|
||||||
const topLevelTasks = allTasks.filter((t) => !t.parent_task_id);
|
|
||||||
const completedTasks = allTasks.filter((t) => t.status === 'completed').length;
|
const completedTasks = allTasks.filter((t) => t.status === 'completed').length;
|
||||||
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length;
|
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length;
|
||||||
const overdueTasks = allTasks.filter(
|
const overdueTasks = allTasks.filter(
|
||||||
@ -143,18 +319,6 @@ export default function ProjectDetail() {
|
|||||||
const isProjectOverdue =
|
const isProjectOverdue =
|
||||||
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
|
project.due_date && project.status !== 'completed' && isPast(parseISO(project.due_date));
|
||||||
|
|
||||||
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
|
|
||||||
setEditingTask(task);
|
|
||||||
setSubtaskParentId(parentId);
|
|
||||||
setShowTaskForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeTaskForm = () => {
|
|
||||||
setShowTaskForm(false);
|
|
||||||
setEditingTask(null);
|
|
||||||
setSubtaskParentId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -187,7 +351,10 @@ export default function ProjectDetail() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
|
{/* Content area */}
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col">
|
||||||
|
{/* Summary section - scrolls with left panel on small, fixed on large */}
|
||||||
|
<div className="px-6 py-5 space-y-5 shrink-0 overflow-y-auto max-h-[50vh] lg:max-h-none lg:overflow-visible">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-sm text-muted-foreground">{project.description}</p>
|
<p className="text-sm text-muted-foreground">{project.description}</p>
|
||||||
@ -197,7 +364,6 @@ export default function ProjectDetail() {
|
|||||||
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Progress section */}
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-baseline justify-between mb-2">
|
<div className="flex items-baseline justify-between mb-2">
|
||||||
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
<span className="text-sm text-muted-foreground">Overall Progress</span>
|
||||||
@ -215,11 +381,7 @@ export default function ProjectDetail() {
|
|||||||
{completedTasks} of {totalTasks} tasks completed
|
{completedTasks} of {totalTasks} tasks completed
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="w-px h-16 bg-border" />
|
<div className="w-px h-16 bg-border" />
|
||||||
|
|
||||||
{/* Mini stats */}
|
|
||||||
<div className="flex items-center gap-5">
|
<div className="flex items-center gap-5">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
|
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
|
||||||
@ -253,8 +415,6 @@ export default function ProjectDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due date */}
|
|
||||||
{project.due_date && (
|
{project.due_date && (
|
||||||
<div className={`flex items-center gap-2 text-sm mt-4 pt-3 border-t border-border ${isProjectOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
|
<div className={`flex items-center gap-2 text-sm mt-4 pt-3 border-t border-border ${isProjectOverdue ? 'text-red-400' : 'text-muted-foreground'}`}>
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-4 w-4" />
|
||||||
@ -264,17 +424,64 @@ export default function ProjectDetail() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Task list header */}
|
{/* Task list header + view controls */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="px-6 pb-3 flex items-center justify-between shrink-0">
|
||||||
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex items-center rounded-md border border-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('kanban')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${
|
||||||
|
viewMode === 'kanban'
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Columns3 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort dropdown (list view only) */}
|
||||||
|
{viewMode === 'list' && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ArrowUpDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<Select
|
||||||
|
value={sortMode}
|
||||||
|
onChange={(e) => setSortMode(e.target.value as SortMode)}
|
||||||
|
className="h-8 text-xs w-auto min-w-[100px]"
|
||||||
|
>
|
||||||
|
<option value="manual">Manual</option>
|
||||||
|
<option value="priority">Priority</option>
|
||||||
|
<option value="due_date">Due Date</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
<Button size="sm" onClick={() => openTaskForm(null, null)}>
|
||||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
Add Task
|
Add Task
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Task list */}
|
{/* Main content: task list/kanban + detail panel */}
|
||||||
|
<div className="flex-1 overflow-hidden flex">
|
||||||
|
{/* Left panel: task list or kanban */}
|
||||||
|
<div className={`overflow-y-auto ${selectedTaskId ? 'w-full lg:w-[55%]' : 'w-full'}`}>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
{topLevelTasks.length === 0 ? (
|
{topLevelTasks.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={ListChecks}
|
icon={ListChecks}
|
||||||
@ -283,171 +490,68 @@ export default function ProjectDetail() {
|
|||||||
actionLabel="Add Task"
|
actionLabel="Add Task"
|
||||||
onAction={() => openTaskForm(null, null)}
|
onAction={() => openTaskForm(null, null)}
|
||||||
/>
|
/>
|
||||||
|
) : viewMode === 'kanban' ? (
|
||||||
|
<KanbanBoard
|
||||||
|
tasks={topLevelTasks}
|
||||||
|
selectedTaskId={selectedTaskId}
|
||||||
|
onSelectTask={(taskId) => setSelectedTaskId(taskId)}
|
||||||
|
onStatusChange={(taskId, status) =>
|
||||||
|
updateTaskStatusMutation.mutate({ taskId, status })
|
||||||
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<DndContext
|
||||||
{topLevelTasks.map((task) => {
|
sensors={sensors}
|
||||||
const progress = getSubtaskProgress(task);
|
collisionDetection={closestCenter}
|
||||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={sortedTasks.map((t) => t.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
disabled={sortMode !== 'manual'}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{sortedTasks.map((task) => {
|
||||||
const isExpanded = expandedTasks.has(task.id);
|
const isExpanded = expandedTasks.has(task.id);
|
||||||
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id}>
|
<div key={task.id}>
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-card-elevated transition-colors duration-150">
|
<SortableTaskRow
|
||||||
{/* Expand/collapse chevron */}
|
task={task}
|
||||||
<button
|
isSelected={selectedTaskId === task.id}
|
||||||
onClick={() => hasSubtasks && toggleExpand(task.id)}
|
isExpanded={isExpanded}
|
||||||
className={`mt-0.5 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
|
showDragHandle={sortMode === 'manual'}
|
||||||
>
|
onSelect={() => setSelectedTaskId(task.id)}
|
||||||
<ChevronRight
|
onToggleExpand={() => toggleExpand(task.id)}
|
||||||
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
|
onToggleStatus={() =>
|
||||||
/>
|
toggleTaskMutation.mutate({
|
||||||
</button>
|
taskId: task.id,
|
||||||
|
status: task.status,
|
||||||
<Checkbox
|
})
|
||||||
checked={task.status === 'completed'}
|
|
||||||
onChange={() =>
|
|
||||||
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
|
|
||||||
}
|
}
|
||||||
disabled={toggleTaskMutation.isPending}
|
togglePending={toggleTaskMutation.isPending}
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
/>
|
||||||
|
{/* Expanded subtasks */}
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={`font-heading font-semibold text-sm ${task.status === 'completed' ? 'line-through text-muted-foreground' : ''}`}>
|
|
||||||
{task.title}
|
|
||||||
</p>
|
|
||||||
{task.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{task.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1.5 mt-1.5">
|
|
||||||
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}>
|
|
||||||
{task.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}>
|
|
||||||
{task.priority}
|
|
||||||
</Badge>
|
|
||||||
{task.due_date && (
|
|
||||||
<span className={`text-[11px] ${task.status !== 'completed' && isPast(parseISO(task.due_date)) ? 'text-red-400' : 'text-muted-foreground'}`}>
|
|
||||||
{format(parseISO(task.due_date), 'MMM d')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtask progress bar */}
|
|
||||||
{progress && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<div className="flex justify-between text-[11px] mb-0.5">
|
|
||||||
<span className="text-muted-foreground">Subtasks</span>
|
|
||||||
<span className="font-medium tabular-nums">
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => openTaskForm(null, task.id)}
|
|
||||||
title="Add subtask"
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => openTaskForm(task, null)}
|
|
||||||
title="Edit task"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm('Delete this task and all its subtasks?')) return;
|
|
||||||
deleteTaskMutation.mutate(task.id);
|
|
||||||
}}
|
|
||||||
disabled={deleteTaskMutation.isPending}
|
|
||||||
title="Delete task"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtasks - shown when expanded */}
|
|
||||||
{isExpanded && hasSubtasks && (
|
{isExpanded && hasSubtasks && (
|
||||||
<div className="ml-9 mt-1 space-y-1">
|
<div className="ml-10 mt-0.5 space-y-0.5">
|
||||||
{task.subtasks.map((subtask) => (
|
{task.subtasks.map((subtask) => (
|
||||||
<div
|
<TaskRow
|
||||||
key={subtask.id}
|
key={subtask.id}
|
||||||
className="flex items-start gap-3 p-2.5 rounded-md border-l-2 border-accent/30 bg-card hover:bg-card-elevated transition-colors duration-150"
|
task={subtask}
|
||||||
>
|
isSelected={selectedTaskId === subtask.id}
|
||||||
<Checkbox
|
isExpanded={false}
|
||||||
checked={subtask.status === 'completed'}
|
showDragHandle={false}
|
||||||
onChange={() =>
|
onSelect={() => setSelectedTaskId(subtask.id)}
|
||||||
|
onToggleExpand={() => {}}
|
||||||
|
onToggleStatus={() =>
|
||||||
toggleTaskMutation.mutate({
|
toggleTaskMutation.mutate({
|
||||||
taskId: subtask.id,
|
taskId: subtask.id,
|
||||||
status: subtask.status,
|
status: subtask.status,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={toggleTaskMutation.isPending}
|
togglePending={toggleTaskMutation.isPending}
|
||||||
className="mt-0.5"
|
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className={`text-sm font-medium ${subtask.status === 'completed' ? 'line-through text-muted-foreground' : ''}`}>
|
|
||||||
{subtask.title}
|
|
||||||
</p>
|
|
||||||
{subtask.description && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">{subtask.description}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1.5 mt-1">
|
|
||||||
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[subtask.status]}`}>
|
|
||||||
{subtask.status.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[subtask.priority]}`}>
|
|
||||||
{subtask.priority}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-0.5 shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={() => openTaskForm(subtask, task.id)}
|
|
||||||
title="Edit subtask"
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (!window.confirm('Delete this subtask?')) return;
|
|
||||||
deleteTaskMutation.mutate(subtask.id);
|
|
||||||
}}
|
|
||||||
disabled={deleteTaskMutation.isPending}
|
|
||||||
title="Delete subtask"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -455,8 +559,55 @@ export default function ProjectDetail() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel: task detail (hidden on small screens) */}
|
||||||
|
{selectedTaskId && (
|
||||||
|
<div className="hidden lg:flex lg:w-[45%] border-l border-border bg-card">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<TaskDetailPanel
|
||||||
|
task={selectedTask}
|
||||||
|
projectId={parseInt(id!)}
|
||||||
|
onEdit={(task) => openTaskForm(task, null)}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: show detail panel as overlay when task selected on small screens */}
|
||||||
|
{selectedTaskId && selectedTask && (
|
||||||
|
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Task Details</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedTaskId(null)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="h-[calc(100%-49px)]">
|
||||||
|
<TaskDetailPanel
|
||||||
|
task={selectedTask}
|
||||||
|
projectId={parseInt(id!)}
|
||||||
|
onEdit={(task) => openTaskForm(task, null)}
|
||||||
|
onDelete={handleDeleteTask}
|
||||||
|
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showTaskForm && (
|
{showTaskForm && (
|
||||||
<TaskForm
|
<TaskForm
|
||||||
|
|||||||
349
frontend/src/components/projects/TaskDetailPanel.tsx
Normal file
349
frontend/src/components/projects/TaskDetailPanel.tsx
Normal file
@ -0,0 +1,349 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||||
|
import {
|
||||||
|
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
|
||||||
|
Calendar, User, Flag, Activity, Send,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import api, { getErrorMessage } from '@/lib/api';
|
||||||
|
import type { ProjectTask, TaskComment, Person } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
const taskStatusColors: Record<string, string> = {
|
||||||
|
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||||
|
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const taskStatusLabels: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-green-500/20 text-green-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
high: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TaskDetailPanelProps {
|
||||||
|
task: ProjectTask | null;
|
||||||
|
projectId: number;
|
||||||
|
onEdit: (task: ProjectTask) => void;
|
||||||
|
onDelete: (taskId: number) => void;
|
||||||
|
onAddSubtask: (parentId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskDetailPanel({
|
||||||
|
task,
|
||||||
|
projectId,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onAddSubtask,
|
||||||
|
}: TaskDetailPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [commentText, setCommentText] = useState('');
|
||||||
|
|
||||||
|
const { data: people = [] } = useQuery({
|
||||||
|
queryKey: ['people'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.get<Person[]>('/people');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSubtaskMutation = useMutation({
|
||||||
|
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||||
|
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
||||||
|
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, {
|
||||||
|
status: newStatus,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCommentMutation = useMutation({
|
||||||
|
mutationFn: async (content: string) => {
|
||||||
|
const { data } = await api.post<TaskComment>(
|
||||||
|
`/projects/${projectId}/tasks/${task!.id}/comments`,
|
||||||
|
{ content }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||||
|
setCommentText('');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error, 'Failed to add comment'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteCommentMutation = useMutation({
|
||||||
|
mutationFn: async (commentId: number) => {
|
||||||
|
await api.delete(`/projects/${projectId}/tasks/${task!.id}/comments/${commentId}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||||
|
toast.success('Comment deleted');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddComment = () => {
|
||||||
|
const trimmed = commentText.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
addCommentMutation.mutate(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<ClipboardList className="h-8 w-8 mb-3 opacity-40" />
|
||||||
|
<p className="text-sm">Select a task to view details</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedPerson = task.person_id
|
||||||
|
? people.find((p) => p.id === task.person_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const comments = task.comments || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h3 className="font-heading text-lg font-semibold leading-tight">
|
||||||
|
{task.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onEdit(task)}
|
||||||
|
title="Edit task"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(task.id)}
|
||||||
|
title="Delete task"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
{/* Fields grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Activity className="h-3 w-3" />
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}>
|
||||||
|
{taskStatusLabels[task.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Flag className="h-3 w-3" />
|
||||||
|
Priority
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
Due Date
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">
|
||||||
|
{task.due_date
|
||||||
|
? format(parseISO(task.due_date), 'MMM d, yyyy')
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
Assigned
|
||||||
|
</div>
|
||||||
|
<p className="text-sm">
|
||||||
|
{assignedPerson ? assignedPerson.name : '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
Description
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtasks */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
Subtasks
|
||||||
|
{task.subtasks.length > 0 && (
|
||||||
|
<span className="ml-1.5 tabular-nums">
|
||||||
|
({task.subtasks.filter((s) => s.status === 'completed').length}/
|
||||||
|
{task.subtasks.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 text-xs px-2"
|
||||||
|
onClick={() => onAddSubtask(task.id)}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{task.subtasks.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{task.subtasks.map((subtask) => (
|
||||||
|
<div
|
||||||
|
key={subtask.id}
|
||||||
|
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={subtask.status === 'completed'}
|
||||||
|
onChange={() =>
|
||||||
|
toggleSubtaskMutation.mutate({
|
||||||
|
taskId: subtask.id,
|
||||||
|
status: subtask.status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={toggleSubtaskMutation.isPending}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm flex-1 ${
|
||||||
|
subtask.status === 'completed'
|
||||||
|
? 'line-through text-muted-foreground'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subtask.title}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded-full ${
|
||||||
|
priorityColors[subtask.priority]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{subtask.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No subtasks yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||||
|
Comments
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<span className="ml-1 tabular-nums">({comments.length})</span>
|
||||||
|
)}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comment list */}
|
||||||
|
{comments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<div
|
||||||
|
key={comment.id}
|
||||||
|
className="group rounded-md bg-secondary/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||||
|
<div className="flex items-center justify-between mt-1.5">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{formatDistanceToNow(parseISO(comment.created_at), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm('Delete this comment?')) return;
|
||||||
|
deleteCommentMutation.mutate(comment.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add comment */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 text-sm resize-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddComment();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 shrink-0 self-end"
|
||||||
|
onClick={handleAddComment}
|
||||||
|
disabled={!commentText.trim() || addCommentMutation.isPending}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
frontend/src/components/projects/TaskRow.tsx
Normal file
137
frontend/src/components/projects/TaskRow.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import { format, isPast, parseISO } from 'date-fns';
|
||||||
|
import { ChevronRight, GripVertical } from 'lucide-react';
|
||||||
|
import type { ProjectTask } from '@/types';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
const taskStatusColors: Record<string, string> = {
|
||||||
|
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||||
|
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
|
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
low: 'bg-green-500/20 text-green-400',
|
||||||
|
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
high: 'bg-red-500/20 text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TaskRowProps {
|
||||||
|
task: ProjectTask;
|
||||||
|
isSelected: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
showDragHandle: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onToggleStatus: () => void;
|
||||||
|
togglePending: boolean;
|
||||||
|
dragHandleProps?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskRow({
|
||||||
|
task,
|
||||||
|
isSelected,
|
||||||
|
isExpanded,
|
||||||
|
showDragHandle,
|
||||||
|
onSelect,
|
||||||
|
onToggleExpand,
|
||||||
|
onToggleStatus,
|
||||||
|
togglePending,
|
||||||
|
dragHandleProps,
|
||||||
|
}: TaskRowProps) {
|
||||||
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||||
|
const completedSubtasks = hasSubtasks
|
||||||
|
? task.subtasks.filter((s) => s.status === 'completed').length
|
||||||
|
: 0;
|
||||||
|
const isOverdue =
|
||||||
|
task.due_date && task.status !== 'completed' && isPast(parseISO(task.due_date));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent/5 border-l-2 border-accent'
|
||||||
|
: 'border-l-2 border-transparent hover:bg-card-elevated'
|
||||||
|
}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
{/* Drag handle */}
|
||||||
|
{showDragHandle && (
|
||||||
|
<div
|
||||||
|
{...dragHandleProps}
|
||||||
|
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expand chevron */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (hasSubtasks) onToggleExpand();
|
||||||
|
}}
|
||||||
|
className={`shrink-0 transition-colors ${
|
||||||
|
hasSubtasks
|
||||||
|
? 'text-muted-foreground hover:text-foreground cursor-pointer'
|
||||||
|
: 'text-transparent cursor-default'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ChevronRight
|
||||||
|
className={`h-3.5 w-3.5 transition-transform duration-200 ${
|
||||||
|
isExpanded ? 'rotate-90' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Checkbox */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={task.status === 'completed'}
|
||||||
|
onChange={onToggleStatus}
|
||||||
|
disabled={togglePending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<span
|
||||||
|
className={`flex-1 text-sm font-medium truncate ${
|
||||||
|
task.status === 'completed' ? 'line-through text-muted-foreground' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Status badge */}
|
||||||
|
<Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 ${taskStatusColors[task.status]}`}>
|
||||||
|
{task.status.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Priority pill */}
|
||||||
|
<Badge
|
||||||
|
className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 ${priorityColors[task.priority]}`}
|
||||||
|
>
|
||||||
|
{task.priority}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
{task.due_date && (
|
||||||
|
<span
|
||||||
|
className={`text-[11px] shrink-0 tabular-nums ${
|
||||||
|
isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{format(parseISO(task.due_date), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Subtask count */}
|
||||||
|
{hasSubtasks && (
|
||||||
|
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||||
|
{completedSubtasks}/{task.subtasks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -99,6 +99,13 @@ export interface Project {
|
|||||||
tasks: ProjectTask[];
|
tasks: ProjectTask[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TaskComment {
|
||||||
|
id: number;
|
||||||
|
task_id: number;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectTask {
|
export interface ProjectTask {
|
||||||
id: number;
|
id: number;
|
||||||
project_id: number;
|
project_id: number;
|
||||||
@ -113,6 +120,7 @@ export interface ProjectTask {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
subtasks: ProjectTask[];
|
subtasks: ProjectTask[];
|
||||||
|
comments: TaskComment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Person {
|
export interface Person {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user