feat: implement Kanban board with drag-and-drop task management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 22:19:51 -05:00
parent 50e8c819fd
commit f0bcc01993
6 changed files with 401 additions and 2 deletions

View File

@@ -0,0 +1,153 @@
import { useMemo, useCallback } from 'react'
import {
DndContext,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
closestCorners,
} from '@dnd-kit/core'
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
useTasks,
useStartTask,
usePauseTask,
useResumeTask,
useCompleteTask,
} from '../api/tasks.ts'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { COLUMN_CONFIG } from '../lib/constants.ts'
import KanbanColumn from './KanbanColumn.tsx'
import TaskCard from './TaskCard.tsx'
interface KanbanBoardProps {
onTaskClick: (id: number) => void
}
export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
const { data: tasks, isLoading } = useTasks()
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
const completeTask = useCompleteTask()
const [activeTask, setActiveTask] = useState<WorkTask | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
)
// Filter to top-level tasks only and group by status
const columns = useMemo(() => {
const topLevel = (tasks ?? []).filter((t) => t.parentTaskId === null)
return COLUMN_CONFIG.map((col) => ({
...col,
tasks: topLevel.filter((t) => t.status === col.status),
}))
}, [tasks])
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const draggedId = Number(event.active.id)
const task = (tasks ?? []).find((t) => t.id === draggedId) ?? null
setActiveTask(task)
},
[tasks]
)
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveTask(null)
const { active, over } = event
if (!over) return
const taskId = Number(active.id)
const task = (tasks ?? []).find((t) => t.id === taskId)
if (!task) return
// Determine target status from the droppable column ID
let targetStatus: number | null = null
const overId = String(over.id)
if (overId.startsWith('column-')) {
targetStatus = Number(overId.replace('column-', ''))
} else {
// Dropped over another card - find which column it belongs to
const overTaskId = Number(over.id)
const overTask = (tasks ?? []).find((t) => t.id === overTaskId)
if (overTask) {
targetStatus = overTask.status
}
}
if (targetStatus === null || targetStatus === task.status) return
// Map transitions to API calls
switch (targetStatus) {
case WorkTaskStatus.Active:
// Works for both Pending->Active and Paused->Active
if (task.status === WorkTaskStatus.Paused) {
resumeTask.mutate({ id: taskId })
} else {
startTask.mutate(taskId)
}
break
case WorkTaskStatus.Paused:
if (task.status === WorkTaskStatus.Active) {
pauseTask.mutate({ id: taskId })
}
break
case WorkTaskStatus.Completed:
completeTask.mutate(taskId)
break
case WorkTaskStatus.Pending:
// Transition back to Pending is not supported
break
}
},
[tasks, startTask, pauseTask, resumeTask, completeTask]
)
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="animate-spin text-[#64748b]" size={32} />
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4 h-full">
{columns.map((col) => (
<KanbanColumn
key={col.status}
status={col.status}
label={col.label}
color={col.color}
tasks={col.tasks}
onTaskClick={onTaskClick}
onAddTask={col.status === 0 ? () => {} : undefined}
/>
))}
</div>
<DragOverlay>
{activeTask ? (
<div className="rotate-2 scale-105">
<TaskCard task={activeTask} onClick={() => {}} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}

View File

@@ -0,0 +1,79 @@
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Plus } from 'lucide-react'
import type { WorkTask } from '../types/index.ts'
import TaskCard from './TaskCard.tsx'
interface KanbanColumnProps {
status: number
label: string
color: string
tasks: WorkTask[]
onTaskClick: (id: number) => void
onAddTask?: () => void
}
export default function KanbanColumn({
status,
label,
color,
tasks,
onTaskClick,
onAddTask,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: `column-${status}` })
const taskIds = tasks.map((t) => t.id)
return (
<div
className={`
flex flex-col min-h-[400px] rounded-xl
bg-white/[0.02] border transition-colors duration-150
${isOver ? 'border-white/20 bg-white/[0.04]' : 'border-white/5'}
`}
>
{/* Column header */}
<div className="px-4 pt-4 pb-3">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-semibold text-white">{label}</h2>
<span
className="text-[11px] font-medium px-2 py-0.5 rounded-full"
style={{
backgroundColor: color + '20',
color,
}}
>
{tasks.length}
</span>
</div>
<div className="h-0.5 rounded-full" style={{ backgroundColor: color }} />
</div>
{/* Cards area */}
<div
ref={setNodeRef}
className="flex-1 flex flex-col gap-2 px-3 pb-3 overflow-y-auto"
>
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
{tasks.map((task) => (
<TaskCard key={task.id} task={task} onClick={onTaskClick} />
))}
</SortableContext>
</div>
{/* Add task button (Pending column only) */}
{status === 0 && onAddTask && (
<button
onClick={onAddTask}
className="flex items-center justify-center gap-1.5 mx-3 mb-3 py-2 rounded-lg
text-xs text-[#64748b] border border-dashed border-white/10
hover:text-white hover:border-white/20 transition-colors duration-150"
>
<Plus size={14} />
Add Task
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,129 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { CheckSquare, Clock } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts'
import type { WorkTask } from '../types/index.ts'
import { CATEGORY_COLORS } from '../lib/constants.ts'
function formatElapsed(task: WorkTask): string | null {
if (!task.startedAt) return null
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const mins = Math.floor((end - start) / 60_000)
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
const remainder = mins % 60
if (hours < 24) return `${hours}h ${remainder}m`
const days = Math.floor(hours / 24)
return `${days}d ${hours % 24}h`
}
interface TaskCardProps {
task: WorkTask
onClick: (id: number) => void
}
export default function TaskCard({ task, onClick }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const categoryColor = CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
const isActive = task.status === WorkTaskStatus.Active
const elapsed = formatElapsed(task)
const completedSubTasks = task.subTasks?.filter(
(s) => s.status === WorkTaskStatus.Completed
).length ?? 0
const totalSubTasks = task.subTasks?.length ?? 0
let progressPercent: number | null = null
if (task.estimatedMinutes && task.startedAt) {
const start = new Date(task.startedAt).getTime()
const end = task.completedAt ? new Date(task.completedAt).getTime() : Date.now()
const elapsedMins = (end - start) / 60_000
progressPercent = Math.min(100, (elapsedMins / task.estimatedMinutes) * 100)
}
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={() => onClick(task.id)}
className={`
relative rounded-lg cursor-grab active:cursor-grabbing
bg-[#1a1d27] hover:-translate-y-0.5 hover:shadow-lg
transition-all duration-150
${isActive ? 'ring-1 ring-cyan-400/60 shadow-[0_0_12px_rgba(6,182,212,0.25)] animate-pulse-glow' : ''}
`}
>
{/* Category left border */}
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
style={{ backgroundColor: categoryColor }}
/>
<div className="pl-4 pr-3 py-3">
{/* Title */}
<p className="text-sm font-medium text-white leading-snug mb-2 truncate">
{task.title}
</p>
{/* Category badge */}
{task.category && (
<span
className="inline-block text-[11px] font-medium px-2 py-0.5 rounded-full mb-2"
style={{
backgroundColor: categoryColor + '20',
color: categoryColor,
}}
>
{task.category}
</span>
)}
{/* Progress bar */}
{progressPercent !== null && (
<div className="h-1 w-full bg-white/5 rounded-full mb-2 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
)}
{/* Footer row */}
<div className="flex items-center justify-between text-[11px] text-[#64748b]">
<div className="flex items-center gap-2">
{elapsed && (
<span className="flex items-center gap-1">
<Clock size={12} />
{elapsed}
</span>
)}
</div>
{totalSubTasks > 0 && (
<span className="flex items-center gap-1">
<CheckSquare size={12} />
{completedSubTasks}/{totalSubTasks}
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -1 +1,14 @@
@import "tailwindcss";
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 8px rgba(6, 182, 212, 0.2);
}
50% {
box-shadow: 0 0 16px rgba(6, 182, 212, 0.4);
}
}
.animate-pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}

View File

@@ -0,0 +1,16 @@
export const COLUMN_CONFIG = [
{ status: 0, label: 'Pending', color: '#94a3b8' },
{ status: 1, label: 'Active', color: '#06b6d4' },
{ status: 2, label: 'Paused', color: '#f59e0b' },
{ status: 3, label: 'Completed', color: '#10b981' },
] as const
export const CATEGORY_COLORS: Record<string, string> = {
Development: '#6366f1',
Research: '#06b6d4',
Communication: '#8b5cf6',
DevOps: '#f97316',
Documentation: '#14b8a6',
Design: '#ec4899',
Unknown: '#64748b',
}

View File

@@ -1,7 +1,16 @@
import { useState } from 'react'
import KanbanBoard from '../components/KanbanBoard.tsx'
export default function Board() {
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
// selectedTaskId will be used by TaskDetailPanel in Task 6
void selectedTaskId
return (
<div>
<h1 className="text-xl font-semibold text-white">Board</h1>
<div className="h-full">
<KanbanBoard onTaskClick={(id) => setSelectedTaskId(id)} />
{/* TaskDetailPanel will be added in Task 6 */}
</div>
)
}