From b8cc446b6b31fd76c85ff5a080db0577571b592b Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Mon, 26 Jan 2026 02:54:25 -0600 Subject: [PATCH] feat: complete US-104 to US-108 (Kanban UI) US-104: Kanban board UI shell - Loading skeleton while fetching - 4 columns with task counts - Empty state with drop zone highlight US-105: Task card component - Type icons and color-coded borders - Priority badges - Project tags - Tooltip on hover for full title - Click opens detail panel US-106: Create task dialog - All fields working - Validation and defaults US-107: Task detail panel (new) - Slide-out sheet from right - Inline editing for all fields - Debounced auto-save (500ms) - Delete with confirmation dialog - Escape key to close - Metadata display (created/updated) US-108: Drag and drop - Visual feedback (ring highlight, ghost card) - Status update on drop - Persists to file via API Added shadcn components: sheet, skeleton, tooltip, alert-dialog --- pnpm-lock.yaml | 70 ++++- web/components.json | 22 ++ web/package.json | 8 +- web/src/components/board/KanbanBoard.tsx | 130 ++++++-- web/src/components/board/KanbanColumn.tsx | 20 +- web/src/components/task/TaskCard.tsx | 118 +++++--- web/src/components/task/TaskDetailPanel.tsx | 313 ++++++++++++++++++++ web/src/components/ui/alert-dialog.tsx | 139 +++++++++ web/src/components/ui/button.tsx | 53 ++-- web/src/components/ui/sheet.tsx | 138 +++++++++ web/src/components/ui/skeleton.tsx | 15 + web/src/components/ui/tooltip.tsx | 32 ++ web/src/globals.css | 58 ++-- web/src/lib/utils.ts | 6 +- web/tailwind.config.js | 89 +++--- 15 files changed, 1037 insertions(+), 174 deletions(-) create mode 100644 web/components.json create mode 100644 web/src/components/task/TaskDetailPanel.tsx create mode 100644 web/src/components/ui/alert-dialog.tsx create mode 100644 web/src/components/ui/sheet.tsx create mode 100644 web/src/components/ui/skeleton.tsx create mode 100644 web/src/components/ui/tooltip.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df1148ab..49b35280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,8 +87,11 @@ importers: '@dnd-kit/utilities': specifier: ^3.2.2 version: 3.2.2(react@19.2.3) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dialog': - specifier: ^1.1.4 + specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-dropdown-menu': specifier: ^2.1.4 @@ -100,11 +103,14 @@ importers: specifier: ^2.1.4 version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-slot': - specifier: ^1.1.1 + specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.9)(react@19.2.3) '@radix-ui/react-toast': specifier: ^1.2.4 version: 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-query': specifier: ^5.62.0 version: 5.90.20(react@19.2.3) @@ -693,6 +699,19 @@ packages: '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.7': resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} peerDependencies: @@ -964,6 +983,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.1': resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} peerDependencies: @@ -2962,6 +2994,20 @@ snapshots: '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -3246,6 +3292,26 @@ snapshots: '@types/react': 19.2.9 '@types/react-dom': 19.2.3(@types/react@19.2.9) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.9)(react@19.2.3) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.9 + '@types/react-dom': 19.2.3(@types/react@19.2.9) + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.9)(react@19.2.3)': dependencies: react: 19.2.3 diff --git a/web/components.json b/web/components.json new file mode 100644 index 00000000..9f7ff54d --- /dev/null +++ b/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/web/package.json b/web/package.json index 02d4ef72..5e2b7156 100644 --- a/web/package.json +++ b/web/package.json @@ -12,17 +12,19 @@ "clean": "rm -rf dist" }, "dependencies": { - "@veritas-kanban/shared": "workspace:*", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@radix-ui/react-dialog": "^1.1.4", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-label": "^2.1.1", "@radix-ui/react-select": "^2.1.4", - "@radix-ui/react-slot": "^1.1.1", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-toast": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.62.0", + "@veritas-kanban/shared": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.468.0", diff --git a/web/src/components/board/KanbanBoard.tsx b/web/src/components/board/KanbanBoard.tsx index aef1367f..93afb978 100644 --- a/web/src/components/board/KanbanBoard.tsx +++ b/web/src/components/board/KanbanBoard.tsx @@ -1,6 +1,8 @@ import { useTasks, useTasksByStatus, useUpdateTask } from '@/hooks/useTasks'; import { KanbanColumn } from './KanbanColumn'; -import type { TaskStatus } from '@veritas-kanban/shared'; +import { TaskDetailPanel } from '@/components/task/TaskDetailPanel'; +import { Skeleton } from '@/components/ui/skeleton'; +import type { TaskStatus, Task } from '@veritas-kanban/shared'; import { DndContext, DragEndEvent, @@ -13,7 +15,6 @@ import { } from '@dnd-kit/core'; import { useState } from 'react'; import { TaskCard } from '@/components/task/TaskCard'; -import type { Task } from '@veritas-kanban/shared'; const COLUMNS: { id: TaskStatus; title: string }[] = [ { id: 'todo', title: 'To Do' }, @@ -22,11 +23,48 @@ const COLUMNS: { id: TaskStatus; title: string }[] = [ { id: 'done', title: 'Done' }, ]; +function LoadingSkeleton() { + return ( +
+ {COLUMNS.map(column => ( +
+
+ + +
+
+ {[1, 2, 3].map(i => ( +
+
+ +
+ + +
+
+
+ + +
+
+ ))} +
+
+ ))} +
+ ); +} + export function KanbanBoard() { const { data: tasks, isLoading, error } = useTasks(); const tasksByStatus = useTasksByStatus(tasks); const updateTask = useUpdateTask(); const [activeTask, setActiveTask] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); const sensors = useSensors( useSensor(PointerSensor, { @@ -61,47 +99,75 @@ export function KanbanBoard() { } }; + const handleTaskClick = (task: Task) => { + setSelectedTask(task); + setDetailOpen(true); + }; + + const handleDetailClose = (open: boolean) => { + setDetailOpen(open); + if (!open) { + // Small delay to allow animation to complete + setTimeout(() => setSelectedTask(null), 200); + } + }; + + // Keep selected task in sync with updated data + const currentSelectedTask = selectedTask + ? tasks?.find(t => t.id === selectedTask.id) || selectedTask + : null; + if (isLoading) { - return ( -
-
Loading tasks...
-
- ); + return ; } if (error) { return (
-
- Error loading tasks: {error.message} +
+
+ Error loading tasks +
+
+ {error.message} +
); } return ( - -
- {COLUMNS.map(column => ( - - ))} -
- - - {activeTask ? ( - - ) : null} - -
+ <> + +
+ {COLUMNS.map(column => ( + + ))} +
+ + + {activeTask ? ( + + ) : null} + +
+ + + ); } diff --git a/web/src/components/board/KanbanColumn.tsx b/web/src/components/board/KanbanColumn.tsx index 2b63357a..0b9ce0c2 100644 --- a/web/src/components/board/KanbanColumn.tsx +++ b/web/src/components/board/KanbanColumn.tsx @@ -7,6 +7,7 @@ interface KanbanColumnProps { id: TaskStatus; title: string; tasks: Task[]; + onTaskClick?: (task: Task) => void; } const columnColors: Record = { @@ -16,16 +17,16 @@ const columnColors: Record = { 'done': 'border-t-green-500', }; -export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) { +export function KanbanColumn({ id, title, tasks, onTaskClick }: KanbanColumnProps) { const { setNodeRef, isOver } = useDroppable({ id }); return (
@@ -39,12 +40,19 @@ export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) {
{tasks.length === 0 ? ( -
- No tasks +
+ {isOver ? 'Drop here' : 'No tasks'}
) : ( tasks.map(task => ( - + onTaskClick?.(task)} + /> )) )}
diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index d4988153..a04b4aa5 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -1,11 +1,18 @@ import { useDraggable } from '@dnd-kit/core'; import { cn } from '@/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; import type { Task, TaskType, TaskPriority } from '@veritas-kanban/shared'; import { Code, Search, FileText, Zap } from 'lucide-react'; interface TaskCardProps { task: Task; isDragging?: boolean; + onClick?: () => void; } const typeIcons: Record = { @@ -28,8 +35,8 @@ const priorityColors: Record = { low: 'bg-slate-500/20 text-slate-400', }; -export function TaskCard({ task, isDragging }: TaskCardProps) { - const { attributes, listeners, setNodeRef, transform } = useDraggable({ +export function TaskCard({ task, isDragging, onClick }: TaskCardProps) { + const { attributes, listeners, setNodeRef, transform, isDragging: isCurrentlyDragging } = useDraggable({ id: task.id, }); @@ -39,49 +46,74 @@ export function TaskCard({ task, isDragging }: TaskCardProps) { } : undefined; + const handleClick = () => { + // Don't open detail panel if we're dragging + if (isCurrentlyDragging || isDragging) return; + onClick?.(); + }; + return ( -
-
- - {typeIcons[task.type]} - -
-

- {task.title} -

+ + + +
+
+ + {typeIcons[task.type]} + +
+

+ {task.title} +

+ {task.description && ( +

+ {task.description} +

+ )} +
+
+ +
+ {task.project && ( + + {task.project} + + )} + + {task.priority} + + {task.tags && task.tags.length > 0 && ( + + +{task.tags.length} tags + + )} +
+
+
+ +

{task.title}

{task.description && ( -

- {task.description} -

+

{task.description}

)} -
-
- -
- {task.project && ( - - {task.project} - - )} - - {task.priority} - -
-
+ + + ); } diff --git a/web/src/components/task/TaskDetailPanel.tsx b/web/src/components/task/TaskDetailPanel.tsx new file mode 100644 index 00000000..802a715b --- /dev/null +++ b/web/src/components/task/TaskDetailPanel.tsx @@ -0,0 +1,313 @@ +import { useEffect, useState, useCallback } from 'react'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useUpdateTask, useDeleteTask } from '@/hooks/useTasks'; +import { Trash2, Code, Search, FileText, Zap, Calendar, Clock } from 'lucide-react'; +import type { Task, TaskType, TaskStatus, TaskPriority } from '@veritas-kanban/shared'; + +interface TaskDetailPanelProps { + task: Task | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const typeIcons: Record = { + code: , + research: , + content: , + automation: , +}; + +const typeLabels: Record = { + code: 'Code', + research: 'Research', + content: 'Content', + automation: 'Automation', +}; + +const statusLabels: Record = { + 'todo': 'To Do', + 'in-progress': 'In Progress', + 'review': 'Review', + 'done': 'Done', +}; + +const priorityLabels: Record = { + low: 'Low', + medium: 'Medium', + high: 'High', +}; + +function useDebouncedSave(task: Task | null, updateTask: ReturnType) { + const [localTask, setLocalTask] = useState(task); + const [isDirty, setIsDirty] = useState(false); + + // Sync with external task when it changes + useEffect(() => { + setLocalTask(task); + setIsDirty(false); + }, [task]); + + // Debounced save + useEffect(() => { + if (!isDirty || !localTask) return; + + const timeout = setTimeout(() => { + updateTask.mutate({ + id: localTask.id, + input: { + title: localTask.title, + description: localTask.description, + type: localTask.type, + status: localTask.status, + priority: localTask.priority, + project: localTask.project, + tags: localTask.tags, + }, + }); + setIsDirty(false); + }, 500); + + return () => clearTimeout(timeout); + }, [localTask, isDirty, updateTask]); + + const updateField = useCallback((field: K, value: Task[K]) => { + setLocalTask(prev => prev ? { ...prev, [field]: value } : null); + setIsDirty(true); + }, []); + + return { localTask, updateField, isDirty }; +} + +export function TaskDetailPanel({ task, open, onOpenChange }: TaskDetailPanelProps) { + const updateTask = useUpdateTask(); + const deleteTask = useDeleteTask(); + const { localTask, updateField, isDirty } = useDebouncedSave(task, updateTask); + + // Handle Escape key + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && open) { + onOpenChange(false); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [open, onOpenChange]); + + const handleDelete = async () => { + if (!task) return; + await deleteTask.mutateAsync(task.id); + onOpenChange(false); + }; + + if (!localTask) return null; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + }; + + return ( + + + +
+ {typeIcons[localTask.type]} + {typeLabels[localTask.type]} Task + {isDirty && ( + Saving... + )} +
+ + updateField('title', e.target.value)} + className="text-xl font-semibold border-0 px-0 focus-visible:ring-0 bg-transparent" + placeholder="Task title..." + /> + +
+ +
+ {/* Description */} +
+ +