Files
adiuva/src/renderer/components/projects/KanbanBoard.tsx
Roberto Musso 60b76c6d97 feat(floating-ai): step 7 — implement morph animation (FLIP)
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>
2026-02-28 13:27:23 +01:00

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