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); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user