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:
Kyle 2026-02-22 03:22:44 +08:00
parent 3a7eb33809
commit 6e50089201
13 changed files with 1324 additions and 306 deletions

View 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")

View File

@ -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",
] ]

View File

@ -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",
)

View 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")

View File

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

View File

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

View 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)

View File

@ -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",

View 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>
);
}

View File

@ -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 priorityColors: Record<string, string> = { const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 };
low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400',
};
function getSubtaskProgress(task: ProjectTask) { function SortableTaskRow({
if (!task.subtasks || task.subtasks.length === 0) return null; task,
const completed = task.subtasks.filter((s) => s.status === 'completed').length; isSelected,
const total = task.subtasks.length; isExpanded,
return { completed, total, percent: (completed / total) * 100 }; 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,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<TaskRow
task={task}
isSelected={isSelected}
isExpanded={isExpanded}
showDragHandle={showDragHandle}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
onToggleStatus={onToggleStatus}
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

View 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>
);
}

View 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>
);
}

View File

@ -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 {