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.person import Person
from app.models.location import Location
from app.models.task_comment import TaskComment
__all__ = [
"Settings",
@ -18,4 +19,5 @@ __all__ = [
"ProjectTask",
"Person",
"Location",
"TaskComment",
]

View File

@ -34,3 +34,7 @@ class ProjectTask(Base):
back_populates="parent_task",
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.orm import selectinload
from typing import List
from pydantic import BaseModel
from app.database import get_db
from app.models.project import Project
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_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.routers.auth import get_current_session
from app.models.settings import Settings
router = APIRouter()
def _project_with_tasks():
"""Eager load projects with tasks and their subtasks."""
return selectinload(Project.tasks).selectinload(ProjectTask.subtasks)
class ReorderItem(BaseModel):
id: int
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])
@ -26,9 +47,9 @@ async def get_projects(
current_user: Settings = Depends(get_current_session)
):
"""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)
projects = result.scalars().all()
projects = result.scalars().unique().all()
return projects
@ -45,7 +66,7 @@ async def create_project(
await db.commit()
# 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)
return result.scalar_one()
@ -57,7 +78,7 @@ async def get_project(
current_user: Settings = Depends(get_current_session)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(_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)
project = result.scalar_one_or_none()
@ -89,7 +110,7 @@ async def update_project(
await db.commit()
# 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)
return result.scalar_one()
@ -128,7 +149,7 @@ async def get_project_tasks(
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
.options(*_task_load_options())
.where(
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None),
@ -136,7 +157,7 @@ async def get_project_tasks(
.order_by(ProjectTask.sort_order.asc())
)
result = await db.execute(query)
tasks = result.scalars().all()
tasks = result.scalars().unique().all()
return tasks
@ -180,13 +201,43 @@ async def create_project_task(
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
.options(*_task_load_options())
.where(ProjectTask.id == new_task.id)
)
result = await db.execute(query)
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)
async def update_project_task(
project_id: int,
@ -217,7 +268,7 @@ async def update_project_task(
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks))
.options(*_task_load_options())
.where(ProjectTask.id == task_id)
)
result = await db.execute(query)
@ -247,3 +298,57 @@ async def delete_project_task(
await db.commit()
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 datetime import datetime, date
from typing import Optional, List, Literal
from app.schemas.task_comment import TaskCommentResponse
TaskStatus = Literal["pending", "in_progress", "completed"]
TaskPriority = Literal["low", "medium", "high"]
@ -41,6 +42,7 @@ class ProjectTaskResponse(BaseModel):
created_at: datetime
updated_at: datetime
subtasks: List["ProjectTaskResponse"] = []
comments: List[TaskCommentResponse] = []
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",
"clsx": "^2.1.1",
"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": {
"@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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, isPast, parseISO } from 'date-fns';
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,
List, Columns3, ArrowUpDown,
} from 'lucide-react';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 { EmptyState } from '@/components/ui/empty-state';
import TaskRow from './TaskRow';
import TaskDetailPanel from './TaskDetailPanel';
import KanbanBoard from './KanbanBoard';
import TaskForm from './TaskForm';
import ProjectForm from './ProjectForm';
@ -30,34 +50,80 @@ const statusLabels: Record<string, string> = {
completed: 'Completed',
};
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',
};
type SortMode = 'manual' | 'priority' | 'due_date';
type ViewMode = 'list' | 'kanban';
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',
};
const PRIORITY_ORDER: Record<string, number> = { high: 0, medium: 1, low: 2 };
function getSubtaskProgress(task: ProjectTask) {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.status === 'completed').length;
const total = task.subtasks.length;
return { completed, total, percent: (completed / total) * 100 };
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,
};
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() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showTaskForm, setShowTaskForm] = useState(false);
const [showProjectForm, setShowProjectForm] = useState(false);
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [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) => {
setExpandedTasks((prev) => {
@ -85,6 +151,9 @@ export default function ProjectDetail() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
},
onError: () => {
toast.error('Failed to update task');
},
});
const deleteTaskMutation = useMutation({
@ -94,6 +163,7 @@ export default function ProjectDetail() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects', id] });
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) {
return (
<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>;
}
const allTasks = project.tasks || [];
const topLevelTasks = allTasks.filter((t) => !t.parent_task_id);
const completedTasks = allTasks.filter((t) => t.status === 'completed').length;
const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress').length;
const overdueTasks = allTasks.filter(
@ -143,18 +319,6 @@ export default function ProjectDetail() {
const isProjectOverdue =
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 (
<div className="flex flex-col h-full">
{/* Header */}
@ -187,277 +351,264 @@ export default function ProjectDetail() {
</Button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-5">
{/* Description */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
{/* 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 */}
{project.description && (
<p className="text-sm text-muted-foreground">{project.description}</p>
)}
{/* Project Summary Card */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-5">
<div className="flex items-center gap-6">
{/* Progress section */}
<div className="flex-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="font-heading text-lg font-bold tabular-nums">
{Math.round(progressPercent)}%
</span>
</div>
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1.5 tabular-nums">
{completedTasks} of {totalTasks} tasks completed
</p>
</div>
{/* Divider */}
<div className="w-px h-16 bg-border" />
{/* Mini stats */}
<div className="flex items-center gap-5">
<div className="text-center">
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
<ListChecks className="h-4 w-4 text-blue-400" />
{/* Project Summary Card */}
<Card className="bg-gradient-to-br from-accent/[0.03] to-transparent">
<CardContent className="p-5">
<div className="flex items-center gap-6">
<div className="flex-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="font-heading text-lg font-bold tabular-nums">
{Math.round(progressPercent)}%
</span>
</div>
<p className="font-heading text-lg font-bold tabular-nums">{totalTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-purple-500/10 mx-auto w-fit mb-1">
<PlayCircle className="h-4 w-4 text-purple-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{inProgressTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Active</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-green-500/10 mx-auto w-fit mb-1">
<CheckCircle2 className="h-4 w-4 text-green-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{completedTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Done</p>
</div>
{overdueTasks > 0 && (
<div className="text-center">
<div className="p-1.5 rounded-md bg-red-500/10 mx-auto w-fit mb-1">
<AlertTriangle className="h-4 w-4 text-red-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums text-red-400">{overdueTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Overdue</p>
</div>
)}
</div>
</div>
{/* 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'}`}>
<Calendar className="h-4 w-4" />
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
{isProjectOverdue && <span className="text-xs font-medium">(Overdue)</span>}
</div>
)}
</CardContent>
</Card>
{/* Task list header */}
<div className="flex items-center justify-between">
<h2 className="font-heading text-lg font-semibold">Tasks</h2>
<Button size="sm" onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
</div>
{/* Task list */}
{topLevelTasks.length === 0 ? (
<EmptyState
icon={ListChecks}
title="No tasks yet"
description="Break this project down into tasks to track your progress."
actionLabel="Add Task"
onAction={() => openTaskForm(null, null)}
/>
) : (
<div className="space-y-2">
{topLevelTasks.map((task) => {
const progress = getSubtaskProgress(task);
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
const isExpanded = expandedTasks.has(task.id);
return (
<div key={task.id}>
<div className="flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-card-elevated transition-colors duration-150">
{/* Expand/collapse chevron */}
<button
onClick={() => hasSubtasks && toggleExpand(task.id)}
className={`mt-0.5 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
>
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
<div className="h-2.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
<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 && (
<div className="ml-9 mt-1 space-y-1">
{task.subtasks.map((subtask) => (
<div
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"
>
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<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>
))}
<p className="text-xs text-muted-foreground mt-1.5 tabular-nums">
{completedTasks} of {totalTasks} tasks completed
</p>
</div>
<div className="w-px h-16 bg-border" />
<div className="flex items-center gap-5">
<div className="text-center">
<div className="p-1.5 rounded-md bg-blue-500/10 mx-auto w-fit mb-1">
<ListChecks className="h-4 w-4 text-blue-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{totalTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Total</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-purple-500/10 mx-auto w-fit mb-1">
<PlayCircle className="h-4 w-4 text-purple-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{inProgressTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Active</p>
</div>
<div className="text-center">
<div className="p-1.5 rounded-md bg-green-500/10 mx-auto w-fit mb-1">
<CheckCircle2 className="h-4 w-4 text-green-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums">{completedTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Done</p>
</div>
{overdueTasks > 0 && (
<div className="text-center">
<div className="p-1.5 rounded-md bg-red-500/10 mx-auto w-fit mb-1">
<AlertTriangle className="h-4 w-4 text-red-400" />
</div>
<p className="font-heading text-lg font-bold tabular-nums text-red-400">{overdueTasks}</p>
<p className="text-[10px] tracking-wider uppercase text-muted-foreground">Overdue</p>
</div>
)}
</div>
);
})}
</div>
{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'}`}>
<Calendar className="h-4 w-4" />
Due {format(parseISO(project.due_date), 'MMM d, yyyy')}
{isProjectOverdue && <span className="text-xs font-medium">(Overdue)</span>}
</div>
)}
</CardContent>
</Card>
</div>
{/* Task list header + view controls */}
<div className="px-6 pb-3 flex items-center justify-between shrink-0">
<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)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Add Task
</Button>
</div>
)}
</div>
{/* 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 ? (
<EmptyState
icon={ListChecks}
title="No tasks yet"
description="Break this project down into tasks to track your progress."
actionLabel="Add Task"
onAction={() => openTaskForm(null, null)}
/>
) : viewMode === 'kanban' ? (
<KanbanBoard
tasks={topLevelTasks}
selectedTaskId={selectedTaskId}
onSelectTask={(taskId) => setSelectedTaskId(taskId)}
onStatusChange={(taskId, status) =>
updateTaskStatusMutation.mutate({ taskId, status })
}
/>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
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 hasSubtasks = task.subtasks && task.subtasks.length > 0;
return (
<div key={task.id}>
<SortableTaskRow
task={task}
isSelected={selectedTaskId === task.id}
isExpanded={isExpanded}
showDragHandle={sortMode === 'manual'}
onSelect={() => setSelectedTaskId(task.id)}
onToggleExpand={() => toggleExpand(task.id)}
onToggleStatus={() =>
toggleTaskMutation.mutate({
taskId: task.id,
status: task.status,
})
}
togglePending={toggleTaskMutation.isPending}
/>
{/* Expanded subtasks */}
{isExpanded && hasSubtasks && (
<div className="ml-10 mt-0.5 space-y-0.5">
{task.subtasks.map((subtask) => (
<TaskRow
key={subtask.id}
task={subtask}
isSelected={selectedTaskId === subtask.id}
isExpanded={false}
showDragHandle={false}
onSelect={() => setSelectedTaskId(subtask.id)}
onToggleExpand={() => {}}
onToggleStatus={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
togglePending={toggleTaskMutation.isPending}
/>
))}
</div>
)}
</div>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</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 && (
<TaskForm
projectId={parseInt(id!)}

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[];
}
export interface TaskComment {
id: number;
task_id: number;
content: string;
created_at: string;
}
export interface ProjectTask {
id: number;
project_id: number;
@ -113,6 +120,7 @@ export interface ProjectTask {
created_at: string;
updated_at: string;
subtasks: ProjectTask[];
comments: TaskComment[];
}
export interface Person {