feat(frontend): Implement Issue management and Kanban board
Add comprehensive Issue management functionality with drag-and-drop Kanban board. Changes: - Created Issue API client (issues.ts) with CRUD operations - Implemented React Query hooks for Issue data management - Added IssueCard component with drag-and-drop support using @dnd-kit - Created KanbanColumn component with droppable zones - Built CreateIssueDialog with form validation using zod - Implemented Kanban page at /projects/[id]/kanban with DnD status changes - Added missing UI components (textarea, select, skeleton) - Enhanced API client with helper methods (get, post, put, patch, delete) - Installed dependencies: @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, @radix-ui/react-select, sonner - Fixed SignalR ConnectionManager TypeScript error - Preserved legacy KanbanBoard component for backward compatibility Features: - Drag and drop issues between Backlog, Todo, InProgress, and Done columns - Real-time status updates via API - Issue creation with type (Story, Task, Bug, Epic) and priority - Visual feedback with priority colors and type icons - Toast notifications for user actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
184
components/features/issues/CreateIssueDialog.tsx
Normal file
184
components/features/issues/CreateIssueDialog.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client';
|
||||
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useCreateIssue } from '@/lib/hooks/use-issues';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
|
||||
const createIssueSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().min(1, 'Description is required'),
|
||||
type: z.enum(['Story', 'Task', 'Bug', 'Epic']),
|
||||
priority: z.enum(['Low', 'Medium', 'High', 'Critical']),
|
||||
});
|
||||
|
||||
interface CreateIssueDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateIssueDialog({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateIssueDialogProps) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createIssueSchema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'Task' as const,
|
||||
priority: 'Medium' as const,
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useCreateIssue(projectId);
|
||||
|
||||
const onSubmit = (data: z.infer<typeof createIssueSchema>) => {
|
||||
createMutation.mutate(data, {
|
||||
onSuccess: () => {
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Issue title..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe the issue..."
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Type</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Story">Story</SelectItem>
|
||||
<SelectItem value="Task">Task</SelectItem>
|
||||
<SelectItem value="Bug">Bug</SelectItem>
|
||||
<SelectItem value="Epic">Epic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="priority"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Priority</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="Low">Low</SelectItem>
|
||||
<SelectItem value="Medium">Medium</SelectItem>
|
||||
<SelectItem value="High">High</SelectItem>
|
||||
<SelectItem value="Critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? 'Creating...' : 'Create Issue'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
58
components/features/kanban/IssueCard.tsx
Normal file
58
components/features/kanban/IssueCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Issue } from '@/lib/api/issues';
|
||||
|
||||
interface IssueCardProps {
|
||||
issue: Issue;
|
||||
}
|
||||
|
||||
export function IssueCard({ issue }: IssueCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: issue.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const priorityColors = {
|
||||
Low: 'bg-gray-100 text-gray-700',
|
||||
Medium: 'bg-blue-100 text-blue-700',
|
||||
High: 'bg-orange-100 text-orange-700',
|
||||
Critical: 'bg-red-100 text-red-700',
|
||||
};
|
||||
|
||||
const typeIcons = {
|
||||
Story: '📖',
|
||||
Task: '✓',
|
||||
Bug: '🐛',
|
||||
Epic: '🚀',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing hover:shadow-md transition-shadow"
|
||||
>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<span>{typeIcons[issue.type]}</span>
|
||||
<h3 className="text-sm font-medium flex-1">{issue.title}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className={priorityColors[issue.priority]}>
|
||||
{issue.priority}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{issue.type}</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import type { KanbanBoard as KanbanBoardType } from '@/types/kanban';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
board: KanbanBoardType;
|
||||
}
|
||||
|
||||
// Legacy KanbanBoard component using old Kanban type
|
||||
// For new Issue-based Kanban, use the page at /projects/[id]/kanban
|
||||
export function KanbanBoard({ board }: KanbanBoardProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -18,7 +20,27 @@ export function KanbanBoard({ board }: KanbanBoardProps) {
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{board.columns.map((column) => (
|
||||
<KanbanColumn key={column.status} column={column} />
|
||||
<div
|
||||
key={column.status}
|
||||
className="flex min-w-[300px] flex-col rounded-lg border-2 bg-muted/50 p-4"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-semibold">{column.title}</h3>
|
||||
<span className="rounded-full bg-background px-2 py-0.5 text-xs font-medium">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{column.tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
{column.tasks.length === 0 && (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
|
||||
<p className="text-sm text-muted-foreground">No tasks</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,39 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { TaskCard } from './TaskCard';
|
||||
import type { KanbanColumn as KanbanColumnType } from '@/types/kanban';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Issue } from '@/lib/api/issues';
|
||||
import { IssueCard } from './IssueCard';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: KanbanColumnType;
|
||||
id: string;
|
||||
title: string;
|
||||
issues: Issue[];
|
||||
}
|
||||
|
||||
export function KanbanColumn({ column }: KanbanColumnProps) {
|
||||
const statusColors = {
|
||||
ToDo: 'border-gray-300',
|
||||
InProgress: 'border-blue-300',
|
||||
InReview: 'border-yellow-300',
|
||||
Done: 'border-green-300',
|
||||
Blocked: 'border-red-300',
|
||||
};
|
||||
export function KanbanColumn({ id, title, issues }: KanbanColumnProps) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[300px] flex-col rounded-lg border-2 bg-muted/50 p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-semibold">{column.title}</h3>
|
||||
<span className="rounded-full bg-background px-2 py-0.5 text-xs font-medium">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
{column.tasks.map((task) => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))}
|
||||
{column.tasks.length === 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<span>{title}</span>
|
||||
<span className="text-muted-foreground">{issues.length}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent ref={setNodeRef} className="space-y-2 min-h-[400px]">
|
||||
<SortableContext
|
||||
items={issues.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{issues.map((issue) => (
|
||||
<IssueCard key={issue.id} issue={issue} />
|
||||
))}
|
||||
</SortableContext>
|
||||
{issues.length === 0 && (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/25">
|
||||
<p className="text-sm text-muted-foreground">No tasks</p>
|
||||
<p className="text-sm text-muted-foreground">No issues</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user