diff --git a/package-lock.json b/package-lock.json index b21c64b..0d88af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@fontsource/geist": "^5.2.8", + "@hello-pangea/dnd": "^18.0.1", "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.161.1", @@ -562,6 +563,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2643,6 +2653,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@hello-pangea/dnd": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz", + "integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.7", + "css-box-model": "^1.2.1", + "raf-schd": "^4.0.3", + "react-redux": "^9.2.0", + "redux": "^5.0.1" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -6443,6 +6470,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -8652,6 +8685,15 @@ "node": ">=12.10" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "license": "MIT", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -15838,6 +15880,12 @@ } } }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -15963,6 +16011,29 @@ "react": "^19.2.4" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -16242,6 +16313,12 @@ "node": ">= 10.13.0" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", diff --git a/package.json b/package.json index 8d1ddaa..85a6434 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "dependencies": { "@fontsource/geist": "^5.2.8", + "@hello-pangea/dnd": "^18.0.1", "@tailwindcss/vite": "^4.2.0", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.161.1", diff --git a/prd.json b/prd.json index 98993f6..f58ce49 100644 --- a/prd.json +++ b/prd.json @@ -260,8 +260,8 @@ "Verify in browser using dev-browser skill" ], "priority": 14, - "passes": false, - "notes": "" + "passes": true, + "notes": "Completed: @hello-pangea/dnd installed, KanbanBoard component with DragDropContext wrapping 3 Droppable columns (To Do/In Progress/Completed), each task is a Draggable wrapping the shared TaskRow component (same UI as global Tasks view), drag-and-drop calls tasks.update({id, status}), '+ Add' Button (variant=ghost, size=sm) per column opens NewTaskDialog with defaultStatus pre-selected, Badge (variant=secondary) task count in each column header, EditTaskDialog for context menu editing, tasks.delete for context menu deletion. Typecheck passes." }, { "id": "US-015", diff --git a/progress.txt b/progress.txt index 5eb333f..e79be37 100644 --- a/progress.txt +++ b/progress.txt @@ -226,3 +226,26 @@ - GanttChart is designed for reuse: the `defaultProjectId` prop on AddCheckpointDialog pre-selects the project and hides the dropdown (for per-project timeline in US-015) - `trpc.projects.listAll.useQuery(undefined, { enabled: showProjectSelect })` prevents unnecessary queries when project is already known --- + +## 2026-02-21 - US-014 +- What was implemented: + - Installed `@hello-pangea/dnd` for drag-and-drop support + - Created `KanbanBoard` component at `src/renderer/components/projects/KanbanBoard.tsx` + - `DragDropContext` wraps 3 `Droppable` columns: To Do (`todo`), In Progress (`in_progress`), Completed (`done`) + - Each task is a `Draggable` wrapping the shared `TaskRow` component (same UI as global Tasks view) + - Drag-and-drop between columns calls `tasks.update({ id, status })` via tRPC mutation + - Each column header shows: status label, `Badge` (variant=secondary) with task count, `Button` (variant=ghost, size=sm) with "+ Add" + - "+ Add" opens `NewTaskDialog` with `defaultProjectId` and `defaultStatus` pre-set to the column's status + - `EditTaskDialog` integrated for right-click context menu editing + - `tasks.delete` integrated for right-click context menu deletion + - Added "Tasks" section with `KanbanBoard` to `ProjectDetail.tsx` below the AI summary card + - Tasks with unknown status values fall back to the "To Do" column + - Drop zones highlight with `bg-muted/50` when dragging over +- Files changed: `src/renderer/components/projects/KanbanBoard.tsx` (new), `src/renderer/components/projects/ProjectDetail.tsx`, `prd.json`, `progress.txt`, `package.json`, `package-lock.json` +- **Learnings for future iterations:** + - `@hello-pangea/dnd` ships its own TypeScript declarations — no `@types/` package needed + - `TaskRow` component from the global Tasks view is fully reusable inside Kanban `Draggable` wrappers — its `ContextMenu` (Edit/Delete) still works correctly inside drag-and-drop contexts + - `NewTaskDialog` accepts `defaultStatus` prop which resets correctly on close via `resetAndClose()` — ideal for column-specific "+ Add" buttons + - When grouping tasks by status for Kanban columns, always handle unknown/null status values with a fallback to prevent tasks from disappearing + - `DragDropContext.onDragEnd` provides `draggableId` which maps directly to `task.id` — no need to look up the task object for status updates +--- diff --git a/src/renderer/components/projects/KanbanBoard.tsx b/src/renderer/components/projects/KanbanBoard.tsx new file mode 100644 index 0000000..0037a26 --- /dev/null +++ b/src/renderer/components/projects/KanbanBoard.tsx @@ -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(null); + + // Group tasks by status + const columns = useMemo(() => { + const tasks = tasksList ?? []; + const grouped: Record = { + 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 ( + <> + +
+ {COLUMNS.map((col) => ( +
+ {/* Column header */} +
+ {col.label} + + {columns[col.id].length} + +
+ + {/* Droppable column */} + + {(provided, snapshot) => ( +
+ {columns[col.id].map((task, index) => ( + + {(dragProvided) => ( +
+ deleteTask.mutate({ id })} + hideBreadcrumb + /> +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ ))} +
+
+ + + { if (!open) setEditTask(null); }} + /> + + ); +} diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index 267bac4..c042c3f 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -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 ( -
- {/* Breadcrumb */} - {breadcrumbPath.length > 0 && ( - - - {breadcrumbPath.map((segment, i) => ( - - {i > 0 && } - {segment} - - ))} - - - )} - - {/* Project Name */} -

{project.name}

+
+ {/* Breadcrumb + Project Name */} +
+ {breadcrumbPath.length > 0 && ( + + + {breadcrumbPath.map((segment, i) => ( + + {i > 0 && } + {segment} + + ))} + + + )} +

{project.name}

+
{/* Stat Cards */}
- - -
- - Notes -
-
- -
{notesCount}
-
-
+ + + + + + {notesCount} + Notes + + - - -
- - Tasks Complete -
-
- -
- {taskStats.done}/{taskStats.total} -
-
-
+ + + + + + {taskStats.done}/{taskStats.total} + Tasks Complete + + - - -
- - Checkpoints -
-
- -
- {checkpointStats.approved}/{checkpointStats.total} -
-
-
+ + + + + + {checkpointStats.approved}/{checkpointStats.total} + Checkpoints + +
{/* AI Project Summary */} - - -
- - AI Project Summary -
-
- -

+ + + + + + AI Project Summary + {project.aiSummary || 'AI summary will appear here'} -

-
-
+ + + + + {/* Tasks Kanban */} +
+
+

Tasks

+ +
+ +
); } diff --git a/src/renderer/components/tasks/TaskRow.tsx b/src/renderer/components/tasks/TaskRow.tsx index 79dd00a..438116f 100644 --- a/src/renderer/components/tasks/TaskRow.tsx +++ b/src/renderer/components/tasks/TaskRow.tsx @@ -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 || diff --git a/src/renderer/routes/projects.tsx b/src/renderer/routes/projects.tsx index e72cafc..4ace510 100644 --- a/src/renderer/routes/projects.tsx +++ b/src/renderer/routes/projects.tsx @@ -1,7 +1,9 @@ import { createFileRoute } from '@tanstack/react-router'; import { z } from 'zod'; +import { FolderKanban } from 'lucide-react'; import { ProjectSidebar } from '@/components/projects/ProjectSidebar'; import { ProjectDetail } from '@/components/projects/ProjectDetail'; +import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'; const searchSchema = z.object({ projectId: z.string().optional(), @@ -30,9 +32,17 @@ function ProjectsPage() { {projectId ? ( ) : ( -
- Select a project to view details -
+ + + + + + No project selected + + Select a project from the sidebar to view its details. + + + )}
diff --git a/src/renderer/routes/tasks.tsx b/src/renderer/routes/tasks.tsx index a2b4d56..5c28675 100644 --- a/src/renderer/routes/tasks.tsx +++ b/src/renderer/routes/tasks.tsx @@ -106,7 +106,7 @@ function TasksPage() { ); return ( -
+
{/* Stat Cards */}
diff --git a/src/renderer/routes/timeline.tsx b/src/renderer/routes/timeline.tsx index c19dc47..a348632 100644 --- a/src/renderer/routes/timeline.tsx +++ b/src/renderer/routes/timeline.tsx @@ -62,7 +62,7 @@ function TimelinePage() { }, [ganttCheckpoints]); return ( -
+
{/* Header */}

Timeline