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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user