diff --git a/TaskTracker.Web/src/components/KanbanBoard.tsx b/TaskTracker.Web/src/components/KanbanBoard.tsx new file mode 100644 index 0000000..e03aebb --- /dev/null +++ b/TaskTracker.Web/src/components/KanbanBoard.tsx @@ -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(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 ( +
+ +
+ ) + } + + return ( + +
+ {columns.map((col) => ( + {} : undefined} + /> + ))} +
+ + + {activeTask ? ( +
+ {}} /> +
+ ) : null} +
+
+ ) +} diff --git a/TaskTracker.Web/src/components/KanbanColumn.tsx b/TaskTracker.Web/src/components/KanbanColumn.tsx new file mode 100644 index 0000000..2005db6 --- /dev/null +++ b/TaskTracker.Web/src/components/KanbanColumn.tsx @@ -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 ( +
+ {/* Column header */} +
+
+

{label}

+ + {tasks.length} + +
+
+
+ + {/* Cards area */} +
+ + {tasks.map((task) => ( + + ))} + +
+ + {/* Add task button (Pending column only) */} + {status === 0 && onAddTask && ( + + )} +
+ ) +} diff --git a/TaskTracker.Web/src/components/TaskCard.tsx b/TaskTracker.Web/src/components/TaskCard.tsx new file mode 100644 index 0000000..1e72794 --- /dev/null +++ b/TaskTracker.Web/src/components/TaskCard.tsx @@ -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 ( +
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 */} +
+ +
+ {/* Title */} +

+ {task.title} +

+ + {/* Category badge */} + {task.category && ( + + {task.category} + + )} + + {/* Progress bar */} + {progressPercent !== null && ( +
+
+
+ )} + + {/* Footer row */} +
+
+ {elapsed && ( + + + {elapsed} + + )} +
+ + {totalSubTasks > 0 && ( + + + {completedSubTasks}/{totalSubTasks} + + )} +
+
+
+ ) +} diff --git a/TaskTracker.Web/src/index.css b/TaskTracker.Web/src/index.css index f1d8c73..8b7e66b 100644 --- a/TaskTracker.Web/src/index.css +++ b/TaskTracker.Web/src/index.css @@ -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; +} diff --git a/TaskTracker.Web/src/lib/constants.ts b/TaskTracker.Web/src/lib/constants.ts new file mode 100644 index 0000000..366fb84 --- /dev/null +++ b/TaskTracker.Web/src/lib/constants.ts @@ -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 = { + Development: '#6366f1', + Research: '#06b6d4', + Communication: '#8b5cf6', + DevOps: '#f97316', + Documentation: '#14b8a6', + Design: '#ec4899', + Unknown: '#64748b', +} diff --git a/TaskTracker.Web/src/pages/Board.tsx b/TaskTracker.Web/src/pages/Board.tsx index b7c7e7e..b53eb1d 100644 --- a/TaskTracker.Web/src/pages/Board.tsx +++ b/TaskTracker.Web/src/pages/Board.tsx @@ -1,7 +1,16 @@ +import { useState } from 'react' +import KanbanBoard from '../components/KanbanBoard.tsx' + export default function Board() { + const [selectedTaskId, setSelectedTaskId] = useState(null) + + // selectedTaskId will be used by TaskDetailPanel in Task 6 + void selectedTaskId + return ( -
-

Board

+
+ setSelectedTaskId(id)} /> + {/* TaskDetailPanel will be added in Task 6 */}
) }