154 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|