feat: Integrate KanbanBoard component with drag-and-drop functionality using @hello-pangea/dnd

This commit is contained in:
Roberto Musso
2026-02-21 01:10:16 +01:00
parent c75788503f
commit 40ac075633
10 changed files with 350 additions and 79 deletions

View File

@@ -0,0 +1,149 @@
import { useState, useMemo, useCallback } from 'react';
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
import { trpc } from '@/lib/trpc';
import { Badge } from '@/components/ui/badge';
import { TaskRow, type TaskItem } from '@/components/tasks/TaskRow';
import { NewTaskDialog } from '@/components/tasks/NewTaskDialog';
import { EditTaskDialog } from '@/components/tasks/EditTaskDialog';
const COLUMNS = [
{ id: 'todo', label: 'To Do' },
{ id: 'in_progress', label: 'In Progress' },
{ id: 'done', label: 'Completed' },
] as const;
type ColumnId = (typeof COLUMNS)[number]['id'];
type KanbanBoardProps = {
projectId: string;
newTaskOpen: boolean;
onNewTaskOpenChange: (open: boolean) => void;
};
export function KanbanBoard({ projectId, newTaskOpen, onNewTaskOpenChange }: KanbanBoardProps) {
const { data: tasksList } = trpc.tasks.list.useQuery({ projectId });
const utils = trpc.useUtils();
const updateTask = trpc.tasks.update.useMutation({
onSuccess: () => void utils.tasks.list.invalidate(),
});
const deleteTask = trpc.tasks.delete.useMutation({
onSuccess: () => void utils.tasks.list.invalidate(),
});
// Edit task dialog state
const [editTask, setEditTask] = useState<TaskItem | null>(null);
// Group tasks by status
const columns = useMemo(() => {
const tasks = tasksList ?? [];
const grouped: Record<ColumnId, TaskItem[]> = {
todo: [],
in_progress: [],
done: [],
};
for (const task of tasks) {
const status = (task.status ?? 'todo') as ColumnId;
if (status in grouped) {
grouped[status].push(task);
} else {
grouped.todo.push(task);
}
}
return grouped;
}, [tasksList]);
const handleDragEnd = useCallback(
(result: DropResult) => {
const { destination, source, draggableId } = result;
if (!destination) return;
if (destination.droppableId === source.droppableId) return;
updateTask.mutate({
id: draggableId,
status: destination.droppableId,
});
},
[updateTask],
);
const handleToggle = useCallback(
(taskId: string, currentStatus: string | null) => {
const nextStatus =
currentStatus === 'todo' ? 'in_progress' :
currentStatus === 'in_progress' ? 'done' : 'todo';
updateTask.mutate({ id: taskId, status: nextStatus });
},
[updateTask],
);
return (
<>
<DragDropContext onDragEnd={handleDragEnd}>
<div className="grid grid-cols-3 gap-4">
{COLUMNS.map((col) => (
<div key={col.id} className="flex flex-col gap-3">
{/* Column header */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{col.label}</span>
<Badge variant="secondary" className="text-xs">
{columns[col.id].length}
</Badge>
</div>
{/* Droppable column */}
<Droppable droppableId={col.id}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`flex flex-col gap-2 min-h-[120px] rounded-md transition-colors ${
snapshot.isDraggingOver ? 'bg-muted/50' : 'bg-muted/20'
}`}
>
{columns[col.id].map((task, index) => (
<Draggable
key={task.id}
draggableId={task.id}
index={index}
>
{(dragProvided) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
<TaskRow
task={task}
onToggle={handleToggle}
onEdit={setEditTask}
onDelete={(id) => deleteTask.mutate({ id })}
hideBreadcrumb
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</div>
))}
</div>
</DragDropContext>
<NewTaskDialog
open={newTaskOpen}
onOpenChange={onNewTaskOpenChange}
defaultProjectId={projectId}
/>
<EditTaskDialog
task={editTask}
open={!!editTask}
onOpenChange={(open) => { if (!open) setEditTask(null); }}
/>
</>
);
}