Add FLIP animation so the floating chat visually morphs into a newly-created TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd';
|
|
import { trpc } from '@/lib/trpc';
|
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
|
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';
|
|
import { TaskDetailDialog } from '@/components/tasks/TaskDetailDialog';
|
|
|
|
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 { state: floatingState } = useFloatingChat();
|
|
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 / view task dialog state
|
|
const [editTask, setEditTask] = useState<TaskItem | null>(null);
|
|
const [viewTask, setViewTask] = useState<TaskItem | null>(null);
|
|
|
|
// Group tasks by status (exclude unapproved AI suggestions)
|
|
const columns = useMemo(() => {
|
|
const tasks = (tasksList ?? []).filter(
|
|
(t) => !(t.isAiSuggested === 1 && t.isApproved === 0),
|
|
);
|
|
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 })}
|
|
onClick={setViewTask}
|
|
hideBreadcrumb
|
|
layoutId={
|
|
floatingState.morphTargetId === `task-morph-${task.id}`
|
|
? floatingState.morphTargetId
|
|
: undefined
|
|
}
|
|
/>
|
|
</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); }}
|
|
/>
|
|
<TaskDetailDialog
|
|
task={viewTask}
|
|
open={!!viewTask}
|
|
onOpenChange={(open) => { if (!open) setViewTask(null); }}
|
|
onEdit={(task) => { setViewTask(null); setEditTask(task); }}
|
|
onDelete={(id) => { deleteTask.mutate({ id }); setViewTask(null); }}
|
|
/>
|
|
</>
|
|
);
|
|
}
|