Files
TaskTracker/TaskTracker.Web/src/components/KanbanBoard.tsx

154 lines
4.3 KiB
TypeScript

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 {
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 {
tasks: WorkTask[]
isLoading: boolean
onTaskClick: (id: number) => void
}
export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) {
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: string | null = null
const overId = String(over.id)
if (overId.startsWith('column-')) {
targetStatus = 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 === WorkTaskStatus.Pending ? () => {} : undefined}
/>
))}
</div>
<DragOverlay>
{activeTask ? (
<div className="rotate-1 scale-[1.03] opacity-90">
<TaskCard task={activeTask} onClick={() => {}} />
</div>
) : null}
</DragOverlay>
</DndContext>
)
}