ProjectDetail overhaul: master-detail layout, comments, sorting, kanban
- Backend: TaskComment model + migration, comment CRUD endpoints, task reorder endpoint, updated selectinload for comments - Frontend: Two-panel master-detail layout with TaskRow (compact) and TaskDetailPanel (full details + comments section) - Sort toolbar: manual (drag-and-drop via @dnd-kit), priority, due date - Kanban board view with drag-and-drop between status columns - Responsive: mobile falls back to overlay panel on task select Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3a7eb33809
commit
6e50089201
36
backend/alembic/versions/009_add_task_comments.py
Normal file
36
backend/alembic/versions/009_add_task_comments.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""add task comments
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-02-22
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "009"
|
||||
down_revision = "008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"task_comments",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"task_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("project_tasks.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("content", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_task_comments_task_id", "task_comments", ["task_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_task_comments_task_id", table_name="task_comments")
|
||||
op.drop_table("task_comments")
|
||||
@ -7,6 +7,7 @@ from app.models.project import Project
|
||||
from app.models.project_task import ProjectTask
|
||||
from app.models.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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
18
backend/app/models/task_comment.py
Normal file
18
backend/app/models/task_comment.py
Normal file
@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Text, Integer, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
|
||||
from datetime import datetime
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class TaskComment(Base):
|
||||
__tablename__ = "task_comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, index=True)
|
||||
task_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
task: Mapped["ProjectTask"] = sa_relationship(back_populates="comments")
|
||||
@ -3,21 +3,42 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
15
backend/app/schemas/task_comment.py
Normal file
15
backend/app/schemas/task_comment.py
Normal file
@ -0,0 +1,15 @@
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TaskCommentCreate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
class TaskCommentResponse(BaseModel):
|
||||
id: int
|
||||
task_id: int
|
||||
content: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@ -23,7 +23,10 @@
|
||||
"sonner": "^1.7.1",
|
||||
"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",
|
||||
|
||||
188
frontend/src/components/projects/KanbanBoard.tsx
Normal file
188
frontend/src/components/projects/KanbanBoard.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import {
|
||||
DndContext,
|
||||
closestCorners,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
useDroppable,
|
||||
useDraggable,
|
||||
} from '@dnd-kit/core';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const COLUMNS: { id: string; label: string; color: string }[] = [
|
||||
{ id: 'pending', label: 'Pending', color: 'text-gray-400' },
|
||||
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
|
||||
{ id: 'completed', label: 'Completed', color: 'text-green-400' },
|
||||
];
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
onStatusChange: (taskId: number, status: string) => void;
|
||||
}
|
||||
|
||||
function KanbanColumn({
|
||||
column,
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
}: {
|
||||
column: (typeof COLUMNS)[0];
|
||||
tasks: ProjectTask[];
|
||||
selectedTaskId: number | null;
|
||||
onSelectTask: (taskId: number) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`flex-1 min-w-[200px] rounded-lg border transition-colors duration-150 ${
|
||||
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
|
||||
}`}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="px-3 py-2.5 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-semibold font-heading ${column.color}`}>
|
||||
{column.label}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-2 space-y-2 min-h-[100px]">
|
||||
{tasks.map((task) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function KanbanCard({
|
||||
task,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: ProjectTask;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: task.id,
|
||||
data: { task },
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate(${transform.x}px, ${transform.y}px)`,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
|
||||
const totalSubtasks = task.subtasks?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={onSelect}
|
||||
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
|
||||
isSelected
|
||||
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
|
||||
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
{task.due_date && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{totalSubtasks > 0 && (
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{completedSubtasks}/{totalSubtasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function KanbanBoard({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onStatusChange,
|
||||
}: KanbanBoardProps) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as number;
|
||||
const newStatus = over.id as string;
|
||||
|
||||
// Only change if dropped on a different column
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
|
||||
onStatusChange(taskId, newStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const tasksByStatus = COLUMNS.map((col) => ({
|
||||
column: col,
|
||||
tasks: tasks.filter((t) => t.status === col.id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{tasksByStatus.map(({ column, tasks: colTasks }) => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
column={column}
|
||||
tasks={colTasks}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@ -1,20 +1,40 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { 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!)}
|
||||
|
||||
349
frontend/src/components/projects/TaskDetailPanel.tsx
Normal file
349
frontend/src/components/projects/TaskDetailPanel.tsx
Normal file
@ -0,0 +1,349 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import {
|
||||
Pencil, Trash2, Plus, MessageSquare, ClipboardList,
|
||||
Calendar, User, Flag, Activity, Send,
|
||||
} from 'lucide-react';
|
||||
import api, { getErrorMessage } from '@/lib/api';
|
||||
import type { ProjectTask, TaskComment, Person } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
};
|
||||
|
||||
const taskStatusLabels: Record<string, string> = {
|
||||
pending: 'Pending',
|
||||
in_progress: 'In Progress',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface TaskDetailPanelProps {
|
||||
task: ProjectTask | null;
|
||||
projectId: number;
|
||||
onEdit: (task: ProjectTask) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
onAddSubtask: (parentId: number) => void;
|
||||
}
|
||||
|
||||
export default function TaskDetailPanel({
|
||||
task,
|
||||
projectId,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAddSubtask,
|
||||
}: TaskDetailPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [commentText, setCommentText] = useState('');
|
||||
|
||||
const { data: people = [] } = useQuery({
|
||||
queryKey: ['people'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Person[]>('/people');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const toggleSubtaskMutation = useMutation({
|
||||
mutationFn: async ({ taskId, status }: { taskId: number; status: string }) => {
|
||||
const newStatus = status === 'completed' ? 'pending' : 'completed';
|
||||
const { data } = await api.put(`/projects/${projectId}/tasks/${taskId}`, {
|
||||
status: newStatus,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
},
|
||||
});
|
||||
|
||||
const addCommentMutation = useMutation({
|
||||
mutationFn: async (content: string) => {
|
||||
const { data } = await api.post<TaskComment>(
|
||||
`/projects/${projectId}/tasks/${task!.id}/comments`,
|
||||
{ content }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
setCommentText('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error, 'Failed to add comment'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCommentMutation = useMutation({
|
||||
mutationFn: async (commentId: number) => {
|
||||
await api.delete(`/projects/${projectId}/tasks/${task!.id}/comments/${commentId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects', projectId.toString()] });
|
||||
toast.success('Comment deleted');
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddComment = () => {
|
||||
const trimmed = commentText.trim();
|
||||
if (!trimmed) return;
|
||||
addCommentMutation.mutate(trimmed);
|
||||
};
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<ClipboardList className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm">Select a task to view details</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const assignedPerson = task.person_id
|
||||
? people.find((p) => p.id === task.person_id)
|
||||
: null;
|
||||
|
||||
const comments = task.comments || [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="font-heading text-lg font-semibold leading-tight">
|
||||
{task.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onEdit(task)}
|
||||
title="Edit task"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(task.id)}
|
||||
title="Delete task"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Fields grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<Activity className="h-3 w-3" />
|
||||
Status
|
||||
</div>
|
||||
<Badge className={`text-[9px] px-1.5 py-0.5 ${taskStatusColors[task.status]}`}>
|
||||
{taskStatusLabels[task.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<Flag className="h-3 w-3" />
|
||||
Priority
|
||||
</div>
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority]}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<Calendar className="h-3 w-3" />
|
||||
Due Date
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{task.due_date
|
||||
? format(parseISO(task.due_date), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
<User className="h-3 w-3" />
|
||||
Assigned
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{assignedPerson ? assignedPerson.name : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{task.description && (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Subtasks
|
||||
{task.subtasks.length > 0 && (
|
||||
<span className="ml-1.5 tabular-nums">
|
||||
({task.subtasks.filter((s) => s.status === 'completed').length}/
|
||||
{task.subtasks.length})
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs px-2"
|
||||
onClick={() => onAddSubtask(task.id)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{task.subtasks.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{task.subtasks.map((subtask) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-card-elevated transition-colors duration-150"
|
||||
>
|
||||
<Checkbox
|
||||
checked={subtask.status === 'completed'}
|
||||
onChange={() =>
|
||||
toggleSubtaskMutation.mutate({
|
||||
taskId: subtask.id,
|
||||
status: subtask.status,
|
||||
})
|
||||
}
|
||||
disabled={toggleSubtaskMutation.isPending}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm flex-1 ${
|
||||
subtask.status === 'completed'
|
||||
? 'line-through text-muted-foreground'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{subtask.title}
|
||||
</span>
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full ${
|
||||
priorityColors[subtask.priority]
|
||||
}`}
|
||||
>
|
||||
{subtask.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">No subtasks yet</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
|
||||
Comments
|
||||
{comments.length > 0 && (
|
||||
<span className="ml-1 tabular-nums">({comments.length})</span>
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{/* Comment list */}
|
||||
{comments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{comments.map((comment) => (
|
||||
<div
|
||||
key={comment.id}
|
||||
className="group rounded-md bg-secondary/50 px-3 py-2"
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatDistanceToNow(parseISO(comment.created_at), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
if (!window.confirm('Delete this comment?')) return;
|
||||
deleteCommentMutation.mutate(comment.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add comment */}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Add a comment..."
|
||||
rows={2}
|
||||
className="flex-1 text-sm resize-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 shrink-0 self-end"
|
||||
onClick={handleAddComment}
|
||||
disabled={!commentText.trim() || addCommentMutation.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
frontend/src/components/projects/TaskRow.tsx
Normal file
137
frontend/src/components/projects/TaskRow.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { format, isPast, parseISO } from 'date-fns';
|
||||
import { ChevronRight, GripVertical } from 'lucide-react';
|
||||
import type { ProjectTask } from '@/types';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
const taskStatusColors: Record<string, string> = {
|
||||
pending: 'bg-gray-500/10 text-gray-400 border-gray-500/20',
|
||||
in_progress: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
completed: 'bg-green-500/10 text-green-400 border-green-500/20',
|
||||
};
|
||||
|
||||
const priorityColors: Record<string, string> = {
|
||||
low: 'bg-green-500/20 text-green-400',
|
||||
medium: 'bg-yellow-500/20 text-yellow-400',
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
|
||||
interface TaskRowProps {
|
||||
task: ProjectTask;
|
||||
isSelected: boolean;
|
||||
isExpanded: boolean;
|
||||
showDragHandle: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleExpand: () => void;
|
||||
onToggleStatus: () => void;
|
||||
togglePending: boolean;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function TaskRow({
|
||||
task,
|
||||
isSelected,
|
||||
isExpanded,
|
||||
showDragHandle,
|
||||
onSelect,
|
||||
onToggleExpand,
|
||||
onToggleStatus,
|
||||
togglePending,
|
||||
dragHandleProps,
|
||||
}: TaskRowProps) {
|
||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||
const completedSubtasks = hasSubtasks
|
||||
? task.subtasks.filter((s) => s.status === 'completed').length
|
||||
: 0;
|
||||
const isOverdue =
|
||||
task.due_date && task.status !== 'completed' && isPast(parseISO(task.due_date));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'bg-accent/5 border-l-2 border-accent'
|
||||
: 'border-l-2 border-transparent hover:bg-card-elevated'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
{showDragHandle && (
|
||||
<div
|
||||
{...dragHandleProps}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expand chevron */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (hasSubtasks) onToggleExpand();
|
||||
}}
|
||||
className={`shrink-0 transition-colors ${
|
||||
hasSubtasks
|
||||
? 'text-muted-foreground hover:text-foreground cursor-pointer'
|
||||
: 'text-transparent cursor-default'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform duration-200 ${
|
||||
isExpanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Checkbox */}
|
||||
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
|
||||
<Checkbox
|
||||
checked={task.status === 'completed'}
|
||||
onChange={onToggleStatus}
|
||||
disabled={togglePending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<span
|
||||
className={`flex-1 text-sm font-medium truncate ${
|
||||
task.status === 'completed' ? 'line-through text-muted-foreground' : ''
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</span>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge className={`text-[9px] px-1.5 py-0.5 shrink-0 ${taskStatusColors[task.status]}`}>
|
||||
{task.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
|
||||
{/* Priority pill */}
|
||||
<Badge
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full shrink-0 ${priorityColors[task.priority]}`}
|
||||
>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
|
||||
{/* Due date */}
|
||||
{task.due_date && (
|
||||
<span
|
||||
className={`text-[11px] shrink-0 tabular-nums ${
|
||||
isOverdue ? 'text-red-400' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{format(parseISO(task.due_date), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Subtask count */}
|
||||
{hasSubtasks && (
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{completedSubtasks}/{task.subtasks.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -99,6 +99,13 @@ export interface Project {
|
||||
tasks: ProjectTask[];
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user