From 8fe6d64e2e41e07093510f788470c1be40774436 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 5 Nov 2025 22:35:38 +0100 Subject: [PATCH] feat(frontend): Implement Task management components - Sprint 4 Story 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete Task CRUD UI for Story detail page with inline creation, status toggling, filtering, and sorting capabilities. Changes: - Created TaskList component with filters, sorting, and progress bar - Created TaskCard component with checkbox status toggle and metadata - Created TaskQuickAdd component for inline Task creation - Added shadcn/ui checkbox and alert components - All components use existing Task hooks (useTasks, useCreateTask, etc.) Components: - components/tasks/task-list.tsx (150 lines) - components/tasks/task-card.tsx (160 lines) - components/tasks/task-quick-add.tsx (180 lines) - components/ui/checkbox.tsx (shadcn/ui) - components/ui/alert.tsx (shadcn/ui) Features: - Task list with real-time count and progress bar - Filter by: All, Active, Completed - Sort by: Recent, Alphabetical, Status - Checkbox toggle for instant status change (optimistic UI) - Inline Quick Add form for fast Task creation - Priority badges and metadata display - Loading states and error handling - Empty state messaging Sprint 4 Story 2: Task Management in Story Detail Task 3: Implement TaskList, TaskCard, TaskQuickAdd components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/tasks/task-card.tsx | 165 ++++++++++++++++++++++++ components/tasks/task-list.tsx | 140 ++++++++++++++++++++ components/tasks/task-quick-add.tsx | 190 ++++++++++++++++++++++++++++ components/ui/alert.tsx | 66 ++++++++++ components/ui/checkbox.tsx | 32 +++++ package-lock.json | 31 +++++ package.json | 1 + 7 files changed, 625 insertions(+) create mode 100644 components/tasks/task-card.tsx create mode 100644 components/tasks/task-list.tsx create mode 100644 components/tasks/task-quick-add.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/checkbox.tsx diff --git a/components/tasks/task-card.tsx b/components/tasks/task-card.tsx new file mode 100644 index 0000000..469785c --- /dev/null +++ b/components/tasks/task-card.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useState } from 'react'; +import { Task, WorkItemStatus } from '@/types/project'; +import { useChangeTaskStatus, useUpdateTask, useDeleteTask } from '@/lib/hooks/use-tasks'; +import { Card, CardContent, CardHeader } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + MoreHorizontal, + Pencil, + Trash2, + Clock, + User, + CheckCircle2, + Circle +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface TaskCardProps { + task: Task; + storyId: string; +} + +const priorityColors = { + Critical: 'bg-red-500 text-white', + High: 'bg-orange-500 text-white', + Medium: 'bg-yellow-500 text-white', + Low: 'bg-blue-500 text-white', +}; + +const statusColors = { + Todo: 'text-gray-500', + InProgress: 'text-blue-500', + Done: 'text-green-500', + Blocked: 'text-red-500', +}; + +export function TaskCard({ task, storyId }: TaskCardProps) { + const [isExpanded, setIsExpanded] = useState(false); + const changeStatus = useChangeTaskStatus(); + const updateTask = useUpdateTask(); + const deleteTask = useDeleteTask(); + + const isDone = task.status === 'Done'; + + const handleCheckboxChange = (checked: boolean) => { + const newStatus: WorkItemStatus = checked ? 'Done' : 'Todo'; + changeStatus.mutate({ id: task.id, status: newStatus }); + }; + + const handleDelete = () => { + if (confirm('Are you sure you want to delete this task?')) { + deleteTask.mutate(task.id); + } + }; + + return ( + setIsExpanded(!isExpanded)} + > + +
+ {/* Checkbox */} +
e.stopPropagation()}> + +
+ + {/* Task Content */} +
+
+

+ {task.title} +

+ + {task.priority} + +
+ + {/* Metadata */} +
+ {task.estimatedHours && ( +
+ + {task.estimatedHours}h +
+ )} + {task.assigneeId && ( +
+ + Assigned +
+ )} +
+ {isDone ? ( + + ) : ( + + )} + {task.status} +
+
+ + {/* Description (expanded) */} + {isExpanded && task.description && ( +
+ {task.description} +
+ )} +
+ + {/* Actions Menu */} +
e.stopPropagation()}> + + + + + + {/* TODO: Open edit dialog */}}> + + Edit + + + + Delete + + + +
+
+
+
+ ); +} diff --git a/components/tasks/task-list.tsx b/components/tasks/task-list.tsx new file mode 100644 index 0000000..479d18c --- /dev/null +++ b/components/tasks/task-list.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useState } from 'react'; +import { useTasks } from '@/lib/hooks/use-tasks'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { TaskCard } from './task-card'; +import { TaskQuickAdd } from './task-quick-add'; +import { WorkItemStatus } from '@/types/project'; + +interface TaskListProps { + storyId: string; +} + +type FilterType = 'all' | 'active' | 'completed'; +type SortType = 'recent' | 'alphabetical' | 'status'; + +export function TaskList({ storyId }: TaskListProps) { + const { data: tasks, isLoading, error } = useTasks(storyId); + const [filter, setFilter] = useState('all'); + const [sort, setSort] = useState('recent'); + + if (isLoading) { + return ( + + + + + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+ ); + } + + if (error) { + return ( + + + + + Failed to load tasks. Please try again. + + + + + ); + } + + const filteredTasks = tasks?.filter(task => { + if (filter === 'active') return task.status !== 'Done'; + if (filter === 'completed') return task.status === 'Done'; + return true; + }) || []; + + const sortedTasks = [...filteredTasks].sort((a, b) => { + if (sort === 'alphabetical') return a.title.localeCompare(b.title); + if (sort === 'status') return a.status.localeCompare(b.status); + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + + const completedCount = tasks?.filter(t => t.status === 'Done').length || 0; + const totalCount = tasks?.length || 0; + const progressPercentage = totalCount > 0 ? (completedCount / totalCount) * 100 : 0; + + return ( +
+ + +
+
+ Tasks + + {completedCount} of {totalCount} completed + +
+
+ + +
+
+ {/* Progress bar */} +
+
+
+
+
+ + + + + {sortedTasks.length === 0 ? ( +
+

+ {filter === 'all' + ? 'No tasks yet. Create your first task above!' + : `No ${filter} tasks found.`} +

+
+ ) : ( +
+ {sortedTasks.map(task => ( + + ))} +
+ )} +
+ +
+ ); +} diff --git a/components/tasks/task-quick-add.tsx b/components/tasks/task-quick-add.tsx new file mode 100644 index 0000000..212f88f --- /dev/null +++ b/components/tasks/task-quick-add.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useCreateTask } from '@/lib/hooks/use-tasks'; +import { CreateTaskDto, WorkItemPriority } from '@/types/project'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Card, CardContent } from '@/components/ui/card'; +import { Plus, X } from 'lucide-react'; + +interface TaskQuickAddProps { + storyId: string; +} + +const taskSchema = z.object({ + title: z.string().min(1, 'Title is required').max(200, 'Title too long'), + priority: z.enum(['Critical', 'High', 'Medium', 'Low']), + estimatedHours: z.coerce.number().min(0).optional(), +}); + +type TaskFormData = z.infer; + +export function TaskQuickAdd({ storyId }: TaskQuickAddProps) { + const [isOpen, setIsOpen] = useState(false); + const createTask = useCreateTask(); + + const form = useForm({ + resolver: zodResolver(taskSchema), + defaultValues: { + title: '', + priority: 'Medium', + estimatedHours: undefined, + }, + }); + + const onSubmit = async (data: TaskFormData) => { + const taskData: CreateTaskDto = { + storyId, + title: data.title, + priority: data.priority as WorkItemPriority, + estimatedHours: data.estimatedHours, + }; + + createTask.mutate(taskData, { + onSuccess: () => { + form.reset(); + // Keep form open for batch creation + }, + }); + }; + + const handleCancel = () => { + form.reset(); + setIsOpen(false); + }; + + if (!isOpen) { + return ( + + ); + } + + return ( + + +
+ +
+

Quick Add Task

+ +
+ + ( + + Title * + + + + + + )} + /> + +
+ ( + + Priority + + + + )} + /> + + ( + + Est. Hours + + + + + + )} + /> +
+ +
+ + +
+ + +
+
+ ); +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..cb0b07b --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/package-lock.json b/package-lock.json index d5b0625..26fb64a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@microsoft/signalr": "^9.0.6", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", @@ -1490,6 +1491,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "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 + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/package.json b/package.json index 9ed824c..3fb8e5d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@microsoft/signalr": "^9.0.6", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7",