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); }}
/>
</>
);
}

View File

@@ -1,19 +1,22 @@
import { useMemo } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { trpc } from '@/lib/trpc';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription } from '@/components/ui/item';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { KanbanBoard } from './KanbanBoard';
type ProjectDetailProps = {
projectId: string;
};
export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [newTaskOpen, setNewTaskOpen] = useState(false);
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery();
const { data: notesList } = trpc.notes.list.useQuery({ projectId });
@@ -68,81 +71,85 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
}
return (
<div className="p-6 max-w-4xl mx-auto flex flex-col gap-6">
{/* Breadcrumb */}
{breadcrumbPath.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
)}
{/* Project Name */}
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
<div className="p-6 pe-8 flex flex-col gap-6">
{/* Breadcrumb + Project Name */}
<div className="flex flex-col gap-1">
{breadcrumbPath.length > 0 && (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbPath.map((segment, i) => (
<BreadcrumbItem key={i}>
{i > 0 && <BreadcrumbSeparator />}
<span className="text-muted-foreground">{segment}</span>
</BreadcrumbItem>
))}
</BreadcrumbList>
</Breadcrumb>
)}
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div>
{/* Stat Cards */}
<div className="grid grid-cols-3 gap-4">
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Notes</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">{notesCount}</div>
</CardContent>
</Card>
<Item variant="muted">
<ItemMedia variant="icon">
<FileText />
</ItemMedia>
<ItemContent>
<ItemTitle>{notesCount}</ItemTitle>
<ItemDescription>Notes</ItemDescription>
</ItemContent>
</Item>
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Tasks Complete</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">
{taskStats.done}/{taskStats.total}
</div>
</CardContent>
</Card>
<Item variant="muted">
<ItemMedia variant="icon">
<CheckCircle2 />
</ItemMedia>
<ItemContent>
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
<ItemDescription>Tasks Complete</ItemDescription>
</ItemContent>
</Item>
<Card className="py-4">
<CardHeader className="pb-0 pt-0">
<div className="flex items-center gap-2">
<Milestone className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium text-muted-foreground">Checkpoints</CardTitle>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-2xl font-semibold">
{checkpointStats.approved}/{checkpointStats.total}
</div>
</CardContent>
</Card>
<Item variant="muted">
<ItemMedia variant="icon">
<Milestone />
</ItemMedia>
<ItemContent>
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
<ItemDescription>Checkpoints</ItemDescription>
</ItemContent>
</Item>
</div>
{/* AI Project Summary */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-sm font-medium">AI Project Summary</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
<Item variant="outline">
<ItemMedia variant="icon">
<Sparkles />
</ItemMedia>
<ItemContent>
<ItemTitle>AI Project Summary</ItemTitle>
<ItemDescription>
{project.aiSummary || 'AI summary will appear here'}
</p>
</CardContent>
</Card>
</ItemDescription>
</ItemContent>
</Item>
{/* Tasks Kanban */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2>
<Button variant="ghost" size="sm" onClick={() => setNewTaskOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
<KanbanBoard
projectId={projectId}
newTaskOpen={newTaskOpen}
onNewTaskOpenChange={setNewTaskOpen}
/>
</div>
</div>
);
}

View File

@@ -49,11 +49,13 @@ export function TaskRow({
onToggle,
onEdit,
onDelete,
hideBreadcrumb,
}: {
task: TaskItem;
onToggle: (id: string, status: string | null) => void;
onEdit?: (task: TaskItem) => void;
onDelete?: (id: string) => void;
hideBreadcrumb?: boolean;
}) {
const isDone = task.status === 'done';
@@ -62,9 +64,11 @@ export function TaskRow({
task.status === 'in_progress' ? 'indeterminate' : false;
const breadcrumb: string[] = [];
if (task.clientName) breadcrumb.push(task.clientName);
if (task.subClientName) breadcrumb.push(task.subClientName);
if (task.projectName) breadcrumb.push(task.projectName);
if (!hideBreadcrumb) {
if (task.clientName) breadcrumb.push(task.clientName);
if (task.subClientName) breadcrumb.push(task.subClientName);
if (task.projectName) breadcrumb.push(task.projectName);
}
const hasMetadata =
task.priority ||