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:
Yaojia Wang
2025-11-04 11:50:01 +01:00
parent 149bb9bd88
commit de697d436b
15 changed files with 1134 additions and 81 deletions

View File

@@ -1,108 +1,204 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { FolderKanban, Plus } from 'lucide-react'; import { useState } from 'react';
import { Button } from '@/components/ui/button'; import { Plus, FolderKanban, Archive, TrendingUp, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useProjects } from '@/lib/hooks/use-projects'; import { useProjects } from '@/lib/hooks/use-projects';
import { CreateProjectDialog } from '@/components/features/projects/CreateProjectDialog';
export default function DashboardPage() { export default function DashboardPage() {
const { data: projects } = useProjects(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const { data: projects, isLoading } = useProjects();
// Calculate statistics
const stats = {
totalProjects: projects?.length || 0,
activeProjects: projects?.filter(p => p.status === 'Active').length || 0,
archivedProjects: projects?.filter(p => p.status === 'Archived').length || 0,
};
// Get recent projects (sort by creation time, take first 5)
const recentProjects = projects
?.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5) || [];
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div> {/* Header */}
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> <div className="flex items-center justify-between">
<p className="text-muted-foreground"> <div>
Welcome to ColaFlow - Your AI-powered project management system <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
</p> <p className="text-muted-foreground">
Welcome back! Here&apos;s an overview of your projects.
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {/* Statistics Cards */}
<div className="grid gap-4 md:grid-cols-3">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle> <CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" /> <FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{projects?.length || 0}</div> {isLoading ? (
<p className="text-xs text-muted-foreground"> <Skeleton className="h-8 w-16" />
Active projects in your workspace ) : (
</p> <>
<div className="text-2xl font-bold">{stats.totalProjects}</div>
<p className="text-xs text-muted-foreground">
Projects in your workspace
</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Projects</CardTitle> <CardTitle className="text-sm font-medium">Active Projects</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" /> <TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> {isLoading ? (
{projects?.filter((p) => p.status === 'Active').length || 0} <Skeleton className="h-8 w-16" />
</div> ) : (
<p className="text-xs text-muted-foreground"> <>
Currently in progress <div className="text-2xl font-bold text-green-600">{stats.activeProjects}</div>
</p> <p className="text-xs text-muted-foreground">
Currently in progress
</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Quick Actions</CardTitle> <CardTitle className="text-sm font-medium">Archived Projects</CardTitle>
<Plus className="h-4 w-4 text-muted-foreground" /> <Archive className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Link href="/projects"> {isLoading ? (
<Button className="w-full" variant="outline"> <Skeleton className="h-8 w-16" />
View All Projects ) : (
</Button> <>
</Link> <div className="text-2xl font-bold text-gray-600">{stats.archivedProjects}</div>
<p className="text-xs text-muted-foreground">
Completed or on hold
</p>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Recent Projects */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Recent Projects</CardTitle> <div className="flex items-center justify-between">
<CardDescription> <div>
Your most recently updated projects <CardTitle>Recent Projects</CardTitle>
</CardDescription> <CardDescription>Your recently created projects</CardDescription>
</div>
<Button variant="ghost" asChild>
<Link href="/projects">
View All
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{projects && projects.length > 0 ? ( {isLoading ? (
<div className="space-y-2"> <div className="space-y-3">
{projects.slice(0, 5).map((project) => ( {[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-[200px]" />
<Skeleton className="h-3 w-[150px]" />
</div>
</div>
))}
</div>
) : recentProjects.length > 0 ? (
<div className="space-y-4">
{recentProjects.map((project) => (
<Link <Link
key={project.id} key={project.id}
href={`/projects/${project.id}`} href={`/projects/${project.id}`}
className="block rounded-lg border p-3 transition-colors hover:bg-accent" className="flex items-center justify-between rounded-lg border p-4 transition-colors hover:bg-accent"
> >
<div className="flex items-center justify-between"> <div className="space-y-1">
<div> <div className="flex items-center gap-2">
<h3 className="font-medium">{project.name}</h3> <h3 className="font-semibold">{project.name}</h3>
<p className="text-sm text-muted-foreground">{project.key}</p> <Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
{project.status}
</Badge>
</div> </div>
<span <p className="text-sm text-muted-foreground">
className={`rounded-full px-2 py-1 text-xs font-medium ${ {project.key} {project.description || 'No description'}
project.status === 'Active' </p>
? 'bg-green-100 text-green-700' </div>
: 'bg-gray-100 text-gray-700' <div className="text-sm text-muted-foreground">
}`} {new Date(project.createdAt).toLocaleDateString()}
>
{project.status}
</span>
</div> </div>
</Link> </Link>
))} ))}
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <div className="flex flex-col items-center justify-center py-8 text-center">
No projects yet. Create your first project to get started. <FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
</p> <p className="text-sm text-muted-foreground mb-4">
No projects yet. Create your first project to get started.
</p>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Project
</Button>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Quick Actions Card */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common tasks to get you started</CardDescription>
</CardHeader>
<CardContent className="grid gap-2 md:grid-cols-2">
<Button
variant="outline"
className="justify-start"
onClick={() => setIsCreateDialogOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Create Project
</Button>
<Button variant="outline" className="justify-start" asChild>
<Link href="/projects">
<FolderKanban className="mr-2 h-4 w-4" />
View All Projects
</Link>
</Button>
</CardContent>
</Card>
<CreateProjectDialog
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,114 @@
'use client';
import { useParams } from 'next/navigation';
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
closestCorners,
} from '@dnd-kit/core';
import { useState } from 'react';
import { useIssues, useChangeIssueStatus } from '@/lib/hooks/use-issues';
import { Button } from '@/components/ui/button';
import { Plus, Loader2 } from 'lucide-react';
import { Issue } from '@/lib/api/issues';
import { KanbanColumn } from '@/components/features/kanban/KanbanColumn';
import { IssueCard } from '@/components/features/kanban/IssueCard';
import { CreateIssueDialog } from '@/components/features/issues/CreateIssueDialog';
const COLUMNS = [
{ id: 'Backlog', title: 'Backlog', color: 'bg-gray-100' },
{ id: 'Todo', title: 'To Do', color: 'bg-blue-100' },
{ id: 'InProgress', title: 'In Progress', color: 'bg-yellow-100' },
{ id: 'Done', title: 'Done', color: 'bg-green-100' },
];
export default function KanbanPage() {
const params = useParams();
const projectId = params.id as string;
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const { data: issues, isLoading } = useIssues(projectId);
const changeStatusMutation = useChangeIssueStatus(projectId);
// Group issues by status
const issuesByStatus = {
Backlog: issues?.filter((i) => i.status === 'Backlog') || [],
Todo: issues?.filter((i) => i.status === 'Todo') || [],
InProgress: issues?.filter((i) => i.status === 'InProgress') || [],
Done: issues?.filter((i) => i.status === 'Done') || [],
};
const handleDragStart = (event: DragStartEvent) => {
const issue = issues?.find((i) => i.id === event.active.id);
setActiveIssue(issue || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveIssue(null);
if (!over || active.id === over.id) return;
const newStatus = over.id as string;
const issue = issues?.find((i) => i.id === active.id);
if (issue && issue.status !== newStatus) {
changeStatusMutation.mutate({ issueId: issue.id, status: newStatus });
}
};
if (isLoading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Kanban Board</h1>
<p className="text-muted-foreground">
Drag and drop to update issue status
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Issue
</Button>
</div>
<DndContext
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4">
{COLUMNS.map((column) => (
<KanbanColumn
key={column.id}
id={column.id}
title={column.title}
issues={issuesByStatus[column.id as keyof typeof issuesByStatus]}
/>
))}
</div>
<DragOverlay>
{activeIssue && <IssueCard issue={activeIssue} />}
</DragOverlay>
</DndContext>
<CreateIssueDialog
projectId={projectId}
open={isCreateDialogOpen}
onOpenChange={setIsCreateDialogOpen}
/>
</div>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,12 +1,14 @@
'use client'; 'use client';
import { KanbanColumn } from './KanbanColumn'; import { TaskCard } from './TaskCard';
import type { KanbanBoard as KanbanBoardType } from '@/types/kanban'; import type { KanbanBoard as KanbanBoardType } from '@/types/kanban';
interface KanbanBoardProps { interface KanbanBoardProps {
board: KanbanBoardType; 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) { export function KanbanBoard({ board }: KanbanBoardProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -18,7 +20,27 @@ export function KanbanBoard({ board }: KanbanBoardProps) {
</div> </div>
<div className="flex gap-4 overflow-x-auto pb-4"> <div className="flex gap-4 overflow-x-auto pb-4">
{board.columns.map((column) => ( {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> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -1,39 +1,43 @@
'use client'; 'use client';
import { TaskCard } from './TaskCard'; import { useDroppable } from '@dnd-kit/core';
import type { KanbanColumn as KanbanColumnType } from '@/types/kanban'; 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 { interface KanbanColumnProps {
column: KanbanColumnType; id: string;
title: string;
issues: Issue[];
} }
export function KanbanColumn({ column }: KanbanColumnProps) { export function KanbanColumn({ id, title, issues }: KanbanColumnProps) {
const statusColors = { const { setNodeRef } = useDroppable({ id });
ToDo: 'border-gray-300',
InProgress: 'border-blue-300',
InReview: 'border-yellow-300',
Done: 'border-green-300',
Blocked: 'border-red-300',
};
return ( return (
<div className="flex min-w-[300px] flex-col rounded-lg border-2 bg-muted/50 p-4"> <Card>
<div className="mb-4 flex items-center justify-between"> <CardHeader className="pb-3">
<h3 className="font-semibold">{column.title}</h3> <CardTitle className="text-sm font-medium flex items-center justify-between">
<span className="rounded-full bg-background px-2 py-0.5 text-xs font-medium"> <span>{title}</span>
{column.tasks.length} <span className="text-muted-foreground">{issues.length}</span>
</span> </CardTitle>
</div> </CardHeader>
<div className="flex-1 space-y-3"> <CardContent ref={setNodeRef} className="space-y-2 min-h-[400px]">
{column.tasks.map((task) => ( <SortableContext
<TaskCard key={task.id} task={task} /> items={issues.map((i) => i.id)}
))} strategy={verticalListSortingStrategy}
{column.tasks.length === 0 && ( >
{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"> <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>
</div> </Card>
); );
} }

160
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,160 @@
'use client';
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -133,3 +133,31 @@ apiClient.interceptors.response.use(
return Promise.reject(error); return Promise.reject(error);
} }
); );
// API helper functions
export const api = {
get: async <T>(url: string, config?: any): Promise<T> => {
const response = await apiClient.get(url, config);
return response.data;
},
post: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.post(url, data, config);
return response.data;
},
put: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.put(url, data, config);
return response.data;
},
patch: async <T>(url: string, data?: any, config?: any): Promise<T> => {
const response = await apiClient.patch(url, data, config);
return response.data;
},
delete: async <T>(url: string, config?: any): Promise<T> => {
const response = await apiClient.delete(url, config);
return response.data;
},
};

84
lib/api/issues.ts Normal file
View File

@@ -0,0 +1,84 @@
import { api } from './client';
export interface Issue {
id: string;
projectId: string;
title: string;
description: string;
type: 'Story' | 'Task' | 'Bug' | 'Epic';
status: 'Backlog' | 'Todo' | 'InProgress' | 'Done';
priority: 'Low' | 'Medium' | 'High' | 'Critical';
assigneeId?: string;
reporterId: string;
createdAt: string;
updatedAt?: string;
}
export interface CreateIssueDto {
title: string;
description: string;
type: string;
priority: string;
}
export interface UpdateIssueDto {
title: string;
description: string;
priority: string;
}
export interface ChangeStatusDto {
status: string;
}
export interface AssignIssueDto {
assigneeId: string | null;
}
export const issuesApi = {
list: async (projectId: string, status?: string): Promise<Issue[]> => {
const params = status ? `?status=${status}` : '';
return api.get<Issue[]>(`/api/v1/projects/${projectId}/issues${params}`);
},
getById: async (projectId: string, id: string): Promise<Issue> => {
return api.get<Issue>(`/api/v1/projects/${projectId}/issues/${id}`);
},
create: async (projectId: string, data: CreateIssueDto): Promise<Issue> => {
return api.post<Issue>(`/api/v1/projects/${projectId}/issues`, data);
},
update: async (
projectId: string,
id: string,
data: UpdateIssueDto
): Promise<Issue> => {
return api.put<Issue>(`/api/v1/projects/${projectId}/issues/${id}`, data);
},
changeStatus: async (
projectId: string,
id: string,
status: string
): Promise<void> => {
return api.put<void>(
`/api/v1/projects/${projectId}/issues/${id}/status`,
{ status }
);
},
assign: async (
projectId: string,
id: string,
assigneeId: string | null
): Promise<void> => {
return api.put<void>(`/api/v1/projects/${projectId}/issues/${id}/assign`, {
assigneeId,
});
},
delete: async (projectId: string, id: string): Promise<void> => {
return api.delete<void>(`/api/v1/projects/${projectId}/issues/${id}`);
},
};

102
lib/hooks/use-issues.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { issuesApi, Issue, CreateIssueDto, UpdateIssueDto } from '@/lib/api/issues';
import { toast } from 'sonner';
export function useIssues(projectId: string, status?: string) {
return useQuery({
queryKey: ['issues', projectId, status],
queryFn: () => issuesApi.list(projectId, status),
enabled: !!projectId,
});
}
export function useIssue(projectId: string, issueId: string) {
return useQuery({
queryKey: ['issue', projectId, issueId],
queryFn: () => issuesApi.getById(projectId, issueId),
enabled: !!projectId && !!issueId,
});
}
export function useCreateIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateIssueDto) => issuesApi.create(projectId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue created successfully');
},
onError: () => {
toast.error('Failed to create issue');
},
});
}
export function useUpdateIssue(projectId: string, issueId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateIssueDto) =>
issuesApi.update(projectId, issueId, data),
onSuccess: (updatedIssue) => {
queryClient.setQueryData(['issue', projectId, issueId], updatedIssue);
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue updated successfully');
},
onError: () => {
toast.error('Failed to update issue');
},
});
}
export function useChangeIssueStatus(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ issueId, status }: { issueId: string; status: string }) =>
issuesApi.changeStatus(projectId, issueId, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
},
onError: () => {
toast.error('Failed to change issue status');
},
});
}
export function useAssignIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
issueId,
assigneeId,
}: {
issueId: string;
assigneeId: string | null;
}) => issuesApi.assign(projectId, issueId, assigneeId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue assigned successfully');
},
onError: () => {
toast.error('Failed to assign issue');
},
});
}
export function useDeleteIssue(projectId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (issueId: string) => issuesApi.delete(projectId, issueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issues', projectId] });
toast.success('Issue deleted successfully');
},
onError: () => {
toast.error('Failed to delete issue');
},
});
}

View File

@@ -78,8 +78,10 @@ export class SignalRConnectionManager {
} }
off(methodName: string, callback?: (...args: any[]) => void): void { off(methodName: string, callback?: (...args: any[]) => void): void {
if (this.connection) { if (this.connection && callback) {
this.connection.off(methodName, callback); this.connection.off(methodName, callback);
} else if (this.connection) {
this.connection.off(methodName);
} }
} }

155
package-lock.json generated
View File

@@ -8,11 +8,15 @@
"name": "colaflow-web", "name": "colaflow-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6", "@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
@@ -24,6 +28,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.66.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.1.12", "zod": "^4.1.12",
"zustand": "^5.0.8" "zustand": "^5.0.8"
@@ -295,6 +300,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
@@ -1270,6 +1328,12 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
"license": "MIT"
},
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
@@ -1717,6 +1781,49 @@
} }
} }
}, },
"node_modules/@radix-ui/react-select": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz",
"integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.1",
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-layout-effect": "1.1.1",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-visually-hidden": "1.2.3",
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.3"
},
"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-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
@@ -1820,6 +1927,21 @@
} }
} }
}, },
"node_modules/@radix-ui/react-use-previous": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-rect": { "node_modules/@radix-ui/react-use-rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
@@ -1856,6 +1978,29 @@
} }
} }
}, },
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"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/rect": { "node_modules/@radix-ui/rect": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -6948,6 +7093,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -10,11 +10,15 @@
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\"" "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6", "@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6", "@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
@@ -26,6 +30,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-hook-form": "^7.66.0", "react-hook-form": "^7.66.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"zod": "^4.1.12", "zod": "^4.1.12",
"zustand": "^5.0.8" "zustand": "^5.0.8"