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:
153
TaskTracker.Web/src/components/KanbanBoard.tsx
Normal file
153
TaskTracker.Web/src/components/KanbanBoard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
TaskTracker.Web/src/components/KanbanColumn.tsx
Normal file
79
TaskTracker.Web/src/components/KanbanColumn.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
TaskTracker.Web/src/components/TaskCard.tsx
Normal file
129
TaskTracker.Web/src/components/TaskCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
TaskTracker.Web/src/lib/constants.ts
Normal file
16
TaskTracker.Web/src/lib/constants.ts
Normal 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',
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user