"use client"; import React, { useState, useMemo } from "react"; import type { Task } from "@mosaic/shared"; import { TaskStatus } from "@mosaic/shared"; import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; import { KanbanColumn } from "./KanbanColumn"; import { TaskCard } from "./TaskCard"; interface KanbanBoardProps { tasks: Task[]; onStatusChange?: (taskId: string, newStatus: TaskStatus) => void; } /** * Map TaskStatus enum to Kanban column configuration * Spec requires: todo, in_progress, review, done */ const columns = [ { status: TaskStatus.NOT_STARTED, title: "To Do" }, { status: TaskStatus.IN_PROGRESS, title: "In Progress" }, { status: TaskStatus.PAUSED, title: "Review" }, { status: TaskStatus.COMPLETED, title: "Done" }, ] as const; /** * Kanban Board component with drag-and-drop functionality * * Features: * - 4 status columns: To Do, In Progress, Review, Done * - Drag-and-drop using @dnd-kit/core * - Task cards with title, priority badge, assignee avatar * - PATCH /api/tasks/:id on status change */ export function KanbanBoard({ tasks, onStatusChange }: KanbanBoardProps): React.ReactElement { const [activeTaskId, setActiveTaskId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, // 8px movement required before drag starts }, }) ); // Group tasks by status const tasksByStatus = useMemo(() => { const grouped: Record = { [TaskStatus.NOT_STARTED]: [], [TaskStatus.IN_PROGRESS]: [], [TaskStatus.PAUSED]: [], [TaskStatus.COMPLETED]: [], [TaskStatus.ARCHIVED]: [], }; (tasks || []).forEach((task) => { if (grouped[task.status]) { grouped[task.status].push(task); } }); // Sort tasks by sortOrder within each column Object.keys(grouped).forEach((status) => { grouped[status as TaskStatus].sort((a, b) => a.sortOrder - b.sortOrder); }); return grouped; }, [tasks]); const activeTask = useMemo( () => (tasks || []).find((task) => task.id === activeTaskId), [tasks, activeTaskId] ); function handleDragStart(event: DragStartEvent): void { setActiveTaskId(event.active.id as string); } async function handleDragEnd(event: DragEndEvent): Promise { const { active, over } = event; if (!over) { setActiveTaskId(null); return; } const taskId = active.id as string; const newStatus = over.id as TaskStatus; // Find the task and check if status actually changed const task = (tasks || []).find((t) => t.id === taskId); if (task && task.status !== newStatus) { // Call PATCH /api/tasks/:id to update status try { const response = await fetch(`/api/tasks/${taskId}`, { method: "PATCH", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ status: newStatus }), }); if (!response.ok) { throw new Error(`Failed to update task status: ${response.statusText}`); } // Optionally call the callback for parent component to refresh if (onStatusChange) { onStatusChange(taskId, newStatus); } } catch (error) { console.error("Error updating task status:", error); // TODO: Show error toast/notification } } setActiveTaskId(null); } return (
{columns.map(({ status, title }) => ( ))}
{/* Drag Overlay - shows a copy of the dragged task */} {activeTask ? (
) : null}
); }