diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx index cf3cbfd..51c07c2 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -1,226 +1,262 @@ -import { - DndContext, - closestCorners, - PointerSensor, - TouchSensor, - useSensor, - useSensors, - type DragEndEvent, - useDroppable, - useDraggable, -} from '@dnd-kit/core'; -import { format, parseISO } from 'date-fns'; -import type { ProjectTask } from '@/types'; -import { Badge } from '@/components/ui/badge'; -import { AssigneeAvatars } from './AssignmentPicker'; - -const COLUMNS: { id: string; label: string; color: string }[] = [ - { id: 'pending', label: 'Pending', color: 'text-gray-400' }, - { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, - { id: 'blocked', label: 'Blocked', color: 'text-red-400' }, - { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, - { id: 'review', label: 'Review', color: 'text-yellow-400' }, - { id: 'completed', label: 'Completed', color: 'text-green-400' }, -]; - -const priorityColors: Record = { - none: 'bg-gray-500/20 text-gray-400', - low: 'bg-green-500/20 text-green-400', - medium: 'bg-yellow-500/20 text-yellow-400', - high: 'bg-red-500/20 text-red-400', -}; - -interface KanbanBoardProps { - tasks: ProjectTask[]; - selectedTaskId: number | null; - kanbanParentTask?: ProjectTask | null; - onSelectTask: (taskId: number) => void; - onStatusChange: (taskId: number, status: string) => void; - onBackToAllTasks?: () => void; -} - -function KanbanColumn({ - column, - tasks, - selectedTaskId, - onSelectTask, -}: { - column: (typeof COLUMNS)[0]; - tasks: ProjectTask[]; - selectedTaskId: number | null; - onSelectTask: (taskId: number) => void; -}) { - const { setNodeRef, isOver } = useDroppable({ id: column.id }); - - return ( -
- {/* Column header */} -
-
- - {column.label} - - - {tasks.length} - -
-
- - {/* Cards */} -
- {tasks.map((task) => ( - onSelectTask(task.id)} - /> - ))} -
-
- ); -} - -function KanbanCard({ - task, - isSelected, - onSelect, -}: { - task: ProjectTask; - isSelected: boolean; - onSelect: () => void; -}) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: task.id, - data: { task }, - }); - - const style = transform - ? { - transform: `translate(${transform.x}px, ${transform.y}px)`, - opacity: isDragging ? 0.5 : 1, - } - : undefined; - - const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; - const totalSubtasks = task.subtasks?.length ?? 0; - - return ( -
-

{task.title}

-
- - {task.priority} - - {task.due_date && ( - - {format(parseISO(task.due_date), 'MMM d')} - - )} - {totalSubtasks > 0 && ( - - {completedSubtasks}/{totalSubtasks} - - )} -
- {/* Assignee avatars */} - {task.assignments && task.assignments.length > 0 && ( -
- -
- )} -
- ); -} - -export default function KanbanBoard({ - tasks, - selectedTaskId, - kanbanParentTask, - onSelectTask, - onStatusChange, - onBackToAllTasks, -}: KanbanBoardProps) { - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) - ); - - // Subtask view is driven by kanbanParentTask (decoupled from selected task) - const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; - const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - if (!over) return; - - const taskId = active.id as number; - const newStatus = over.id as string; - - const task = activeTasks.find((t) => t.id === taskId); - if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { - onStatusChange(taskId, newStatus); - } - }; - - const tasksByStatus = COLUMNS.map((col) => ({ - column: col, - tasks: activeTasks.filter((t) => t.status === col.id), - })); - - return ( -
- {/* Subtask view header */} - {isSubtaskView && kanbanParentTask && ( -
- - / - - Subtasks of: {kanbanParentTask.title} - -
- )} - - -
- {tasksByStatus.map(({ column, tasks: colTasks }) => ( - - ))} -
-
-
- ); -} +import { useState, useCallback } from 'react'; +import { + DndContext, + closestCenter, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + type DragStartEvent, + type DragEndEvent, + useDroppable, + useDraggable, + DragOverlay, +} from '@dnd-kit/core'; +import { format, parseISO } from 'date-fns'; +import type { ProjectTask } from '@/types'; +import { Badge } from '@/components/ui/badge'; +import { AssigneeAvatars } from './AssignmentPicker'; + +const COLUMNS: { id: string; label: string; color: string }[] = [ + { id: 'pending', label: 'Pending', color: 'text-gray-400' }, + { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, + { id: 'blocked', label: 'Blocked', color: 'text-red-400' }, + { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, + { id: 'review', label: 'Review', color: 'text-yellow-400' }, + { id: 'completed', label: 'Completed', color: 'text-green-400' }, +]; + +const priorityColors: Record = { + none: 'bg-gray-500/20 text-gray-400', + low: 'bg-green-500/20 text-green-400', + medium: 'bg-yellow-500/20 text-yellow-400', + high: 'bg-red-500/20 text-red-400', +}; + +interface KanbanBoardProps { + tasks: ProjectTask[]; + selectedTaskId: number | null; + kanbanParentTask?: ProjectTask | null; + onSelectTask: (taskId: number) => void; + onStatusChange: (taskId: number, status: string) => void; + onBackToAllTasks?: () => void; +} + +function KanbanColumn({ + column, + tasks, + selectedTaskId, + draggingId, + onSelectTask, +}: { + column: (typeof COLUMNS)[0]; + tasks: ProjectTask[]; + selectedTaskId: number | null; + draggingId: number | null; + onSelectTask: (taskId: number) => void; +}) { + const { setNodeRef, isOver } = useDroppable({ id: column.id }); + + return ( +
+ {/* Column header */} +
+
+ + {column.label} + + + {tasks.length} + +
+
+ + {/* Cards */} +
+ {tasks.map((task) => ( + onSelectTask(task.id)} + /> + ))} +
+
+ ); +} + +// Card content — shared between in-place card and drag overlay +function CardContent({ task, isSelected, ghost }: { task: ProjectTask; isSelected: boolean; ghost?: boolean }) { + const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; + const totalSubtasks = task.subtasks?.length ?? 0; + + return ( +
+

{task.title}

+
+ + {task.priority} + + {task.due_date && ( + + {format(parseISO(task.due_date), 'MMM d')} + + )} + {totalSubtasks > 0 && ( + + {completedSubtasks}/{totalSubtasks} + + )} +
+ {task.assignments && task.assignments.length > 0 && ( +
+ +
+ )} +
+ ); +} + +function KanbanCard({ + task, + isSelected, + isDragSource, + onSelect, +}: { + task: ProjectTask; + isSelected: boolean; + isDragSource: boolean; + onSelect: () => void; +}) { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: task.id, + data: { task }, + }); + + return ( +
+ +
+ ); +} + +export default function KanbanBoard({ + tasks, + selectedTaskId, + kanbanParentTask, + onSelectTask, + onStatusChange, + onBackToAllTasks, +}: KanbanBoardProps) { + const [draggingId, setDraggingId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) + ); + + const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; + const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; + + const draggingTask = draggingId ? activeTasks.find((t) => t.id === draggingId) ?? null : null; + + const handleDragStart = useCallback((event: DragStartEvent) => { + setDraggingId(event.active.id as number); + }, []); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + setDraggingId(null); + const { active, over } = event; + if (!over) return; + + const taskId = active.id as number; + const newStatus = over.id as string; + + const task = activeTasks.find((t) => t.id === taskId); + if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { + onStatusChange(taskId, newStatus); + } + }, [activeTasks, onStatusChange]); + + const handleDragCancel = useCallback(() => { + setDraggingId(null); + }, []); + + const tasksByStatus = COLUMNS.map((col) => ({ + column: col, + tasks: activeTasks.filter((t) => t.status === col.id), + })); + + return ( +
+ {/* Subtask view header */} + {isSubtaskView && kanbanParentTask && ( +
+ + / + + Subtasks of: {kanbanParentTask.title} + +
+ )} + + +
+ {tasksByStatus.map(({ column, tasks: colTasks }) => ( + + ))} +
+ + {/* Floating overlay — renders above everything, no layout impact */} + + {draggingTask ? ( +
+ +
+ ) : null} +
+
+
+ ); +}