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";
|
@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() {
|
export default function Board() {
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// selectedTaskId will be used by TaskDetailPanel in Task 6
|
||||||
|
void selectedTaskId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="h-full">
|
||||||
<h1 className="text-xl font-semibold text-white">Board</h1>
|
<KanbanBoard onTaskClick={(id) => setSelectedTaskId(id)} />
|
||||||
|
{/* TaskDetailPanel will be added in Task 6 */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user