feat(frontend): Implement Task management components - Sprint 4 Story 2

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 <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-05 22:35:38 +01:00
parent f7a17a3d1a
commit 8fe6d64e2e
7 changed files with 625 additions and 0 deletions

View File

@@ -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 (
<Card
className={cn(
"transition-all duration-200 hover:shadow-md cursor-pointer",
isDone && "opacity-60"
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<CardHeader className="p-4">
<div className="flex items-start gap-3">
{/* Checkbox */}
<div onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isDone}
onCheckedChange={handleCheckboxChange}
disabled={changeStatus.isPending}
className="mt-1"
/>
</div>
{/* Task Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<h4 className={cn(
"font-medium text-sm",
isDone && "line-through text-muted-foreground"
)}>
{task.title}
</h4>
<Badge
variant="secondary"
className={cn("text-xs", priorityColors[task.priority])}
>
{task.priority}
</Badge>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
{task.estimatedHours && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{task.estimatedHours}h</span>
</div>
)}
{task.assigneeId && (
<div className="flex items-center gap-1">
<User className="w-3 h-3" />
<span>Assigned</span>
</div>
)}
<div className={cn("flex items-center gap-1", statusColors[task.status])}>
{isDone ? (
<CheckCircle2 className="w-3 h-3" />
) : (
<Circle className="w-3 h-3" />
)}
<span>{task.status}</span>
</div>
</div>
{/* Description (expanded) */}
{isExpanded && task.description && (
<div className="mt-3 text-sm text-muted-foreground">
{task.description}
</div>
)}
</div>
{/* Actions Menu */}
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
>
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {/* TODO: Open edit dialog */}}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleDelete}
className="text-destructive"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
</Card>
);
}

View File

@@ -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<FilterType>('all');
const [sort, setSort] = useState<SortType>('recent');
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-8 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (error) {
return (
<Card>
<CardContent className="pt-6">
<Alert variant="destructive">
<AlertDescription>
Failed to load tasks. Please try again.
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
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 (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Tasks</CardTitle>
<CardDescription>
{completedCount} of {totalCount} completed
</CardDescription>
</div>
<div className="flex items-center gap-4">
<Select value={filter} onValueChange={(v) => setFilter(v as FilterType)}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
</SelectContent>
</Select>
<Select value={sort} onValueChange={(v) => setSort(v as SortType)}>
<SelectTrigger className="w-[160px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="recent">Recent</SelectItem>
<SelectItem value="alphabetical">Alphabetical</SelectItem>
<SelectItem value="status">By Status</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Progress bar */}
<div className="mt-4">
<div className="h-2 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<TaskQuickAdd storyId={storyId} />
{sortedTasks.length === 0 ? (
<div className="text-center py-12">
<p className="text-muted-foreground">
{filter === 'all'
? 'No tasks yet. Create your first task above!'
: `No ${filter} tasks found.`}
</p>
</div>
) : (
<div className="space-y-3">
{sortedTasks.map(task => (
<TaskCard key={task.id} task={task} storyId={storyId} />
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<typeof taskSchema>;
export function TaskQuickAdd({ storyId }: TaskQuickAddProps) {
const [isOpen, setIsOpen] = useState(false);
const createTask = useCreateTask();
const form = useForm<TaskFormData>({
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 (
<Button
onClick={() => setIsOpen(true)}
variant="outline"
className="w-full"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Add Task
</Button>
);
}
return (
<Card className="border-dashed">
<CardContent className="pt-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">Quick Add Task</h4>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCancel}
className="h-6 w-6 p-0"
>
<X className="w-4 h-4" />
</Button>
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title *</FormLabel>
<FormControl>
<Input
placeholder="e.g., Implement login API"
{...field}
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="priority"
render={({ field }) => (
<FormItem>
<FormLabel>Priority</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="Critical">Critical</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Medium">Medium</SelectItem>
<SelectItem value="Low">Low</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="estimatedHours"
render={({ field }) => (
<FormItem>
<FormLabel>Est. Hours</FormLabel>
<FormControl>
<Input
type="number"
placeholder="8"
{...field}
value={field.value ?? ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex gap-2">
<Button
type="submit"
size="sm"
disabled={createTask.isPending}
className="flex-1"
>
{createTask.isPending ? 'Creating...' : 'Add Task'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleCancel}
>
Cancel
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
}

66
components/ui/alert.tsx Normal file
View File

@@ -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<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -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<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

31
package-lock.json generated
View File

@@ -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",

View File

@@ -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",