feat: Integrate KanbanBoard component with drag-and-drop functionality using @hello-pangea/dnd
This commit is contained in:
149
src/renderer/components/projects/KanbanBoard.tsx
Normal file
149
src/renderer/components/projects/KanbanBoard.tsx
Normal 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); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user