- {projects.slice(0, 5).map((project) => (
+ {isLoading ? (
+
+ {recentProjects.map((project) => (
-
-
-
{project.name}
-
{project.key}
+
+
+
{project.name}
+
+ {project.status}
+
-
- {project.status}
-
+
+ {project.key} • {project.description || 'No description'}
+
+
+
+ {new Date(project.createdAt).toLocaleDateString()}
))}
) : (
-
- No projects yet. Create your first project to get started.
-
+
+
+
+ No projects yet. Create your first project to get started.
+
+
+
)}
+
+ {/* Quick Actions Card */}
+
+
+ Quick Actions
+ Common tasks to get you started
+
+
+
+
+
+
+
+
);
}
diff --git a/app/(dashboard)/projects/[id]/kanban/page.tsx b/app/(dashboard)/projects/[id]/kanban/page.tsx
new file mode 100644
index 0000000..22ae338
--- /dev/null
+++ b/app/(dashboard)/projects/[id]/kanban/page.tsx
@@ -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
(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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
Kanban Board
+
+ Drag and drop to update issue status
+
+
+
+
+
+
+
+ {COLUMNS.map((column) => (
+
+ ))}
+
+
+
+ {activeIssue && }
+
+
+
+
+
+ );
+}
diff --git a/components/features/issues/CreateIssueDialog.tsx b/components/features/issues/CreateIssueDialog.tsx
new file mode 100644
index 0000000..e1db188
--- /dev/null
+++ b/components/features/issues/CreateIssueDialog.tsx
@@ -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) => {
+ createMutation.mutate(data, {
+ onSuccess: () => {
+ form.reset();
+ onOpenChange(false);
+ },
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/components/features/kanban/IssueCard.tsx b/components/features/kanban/IssueCard.tsx
new file mode 100644
index 0000000..83bbb52
--- /dev/null
+++ b/components/features/kanban/IssueCard.tsx
@@ -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 (
+
+
+
+ {typeIcons[issue.type]}
+
{issue.title}
+
+
+
+ {issue.priority}
+
+ {issue.type}
+
+
+
+ );
+}
diff --git a/components/features/kanban/KanbanBoard.tsx b/components/features/kanban/KanbanBoard.tsx
index 05f9bce..dd98ec8 100644
--- a/components/features/kanban/KanbanBoard.tsx
+++ b/components/features/kanban/KanbanBoard.tsx
@@ -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 (
@@ -18,7 +20,27 @@ export function KanbanBoard({ board }: KanbanBoardProps) {
{board.columns.map((column) => (
-
+
+
+
{column.title}
+
+ {column.tasks.length}
+
+
+
+ {column.tasks.map((task) => (
+
+ ))}
+ {column.tasks.length === 0 && (
+
+ )}
+
+
))}
diff --git a/components/features/kanban/KanbanColumn.tsx b/components/features/kanban/KanbanColumn.tsx
index fd1b321..91f20af 100644
--- a/components/features/kanban/KanbanColumn.tsx
+++ b/components/features/kanban/KanbanColumn.tsx
@@ -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 (
-
-
-
{column.title}
-
- {column.tasks.length}
-
-
-
- {column.tasks.map((task) => (
-
- ))}
- {column.tasks.length === 0 && (
+
+
+
+ {title}
+ {issues.length}
+
+
+
+ i.id)}
+ strategy={verticalListSortingStrategy}
+ >
+ {issues.map((issue) => (
+
+ ))}
+
+ {issues.length === 0 && (
)}
-
-
+
+
);
}
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
new file mode 100644
index 0000000..9f3ef21
--- /dev/null
+++ b/components/ui/select.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1',
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = 'popper', ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};
diff --git a/components/ui/skeleton.tsx b/components/ui/skeleton.tsx
new file mode 100644
index 0000000..a626d9b
--- /dev/null
+++ b/components/ui/skeleton.tsx
@@ -0,0 +1,15 @@
+import { cn } from '@/lib/utils';
+
+function Skeleton({
+ className,
+ ...props
+}: React.HTMLAttributes) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx
new file mode 100644
index 0000000..4ca0611
--- /dev/null
+++ b/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Textarea.displayName = 'Textarea';
+
+export { Textarea };
diff --git a/lib/api/client.ts b/lib/api/client.ts
index 78dd82d..1cf9688 100644
--- a/lib/api/client.ts
+++ b/lib/api/client.ts
@@ -133,3 +133,31 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
);
+
+// API helper functions
+export const api = {
+ get: async (url: string, config?: any): Promise => {
+ const response = await apiClient.get(url, config);
+ return response.data;
+ },
+
+ post: async (url: string, data?: any, config?: any): Promise => {
+ const response = await apiClient.post(url, data, config);
+ return response.data;
+ },
+
+ put: async (url: string, data?: any, config?: any): Promise => {
+ const response = await apiClient.put(url, data, config);
+ return response.data;
+ },
+
+ patch: async (url: string, data?: any, config?: any): Promise => {
+ const response = await apiClient.patch(url, data, config);
+ return response.data;
+ },
+
+ delete: async (url: string, config?: any): Promise => {
+ const response = await apiClient.delete(url, config);
+ return response.data;
+ },
+};
diff --git a/lib/api/issues.ts b/lib/api/issues.ts
new file mode 100644
index 0000000..8b376d1
--- /dev/null
+++ b/lib/api/issues.ts
@@ -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 => {
+ const params = status ? `?status=${status}` : '';
+ return api.get(`/api/v1/projects/${projectId}/issues${params}`);
+ },
+
+ getById: async (projectId: string, id: string): Promise => {
+ return api.get(`/api/v1/projects/${projectId}/issues/${id}`);
+ },
+
+ create: async (projectId: string, data: CreateIssueDto): Promise => {
+ return api.post(`/api/v1/projects/${projectId}/issues`, data);
+ },
+
+ update: async (
+ projectId: string,
+ id: string,
+ data: UpdateIssueDto
+ ): Promise => {
+ return api.put(`/api/v1/projects/${projectId}/issues/${id}`, data);
+ },
+
+ changeStatus: async (
+ projectId: string,
+ id: string,
+ status: string
+ ): Promise => {
+ return api.put(
+ `/api/v1/projects/${projectId}/issues/${id}/status`,
+ { status }
+ );
+ },
+
+ assign: async (
+ projectId: string,
+ id: string,
+ assigneeId: string | null
+ ): Promise => {
+ return api.put(`/api/v1/projects/${projectId}/issues/${id}/assign`, {
+ assigneeId,
+ });
+ },
+
+ delete: async (projectId: string, id: string): Promise => {
+ return api.delete(`/api/v1/projects/${projectId}/issues/${id}`);
+ },
+};
diff --git a/lib/hooks/use-issues.ts b/lib/hooks/use-issues.ts
new file mode 100644
index 0000000..72ca04d
--- /dev/null
+++ b/lib/hooks/use-issues.ts
@@ -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');
+ },
+ });
+}
diff --git a/lib/signalr/ConnectionManager.ts b/lib/signalr/ConnectionManager.ts
index e37bd55..7cf5d21 100644
--- a/lib/signalr/ConnectionManager.ts
+++ b/lib/signalr/ConnectionManager.ts
@@ -78,8 +78,10 @@ export class SignalRConnectionManager {
}
off(methodName: string, callback?: (...args: any[]) => void): void {
- if (this.connection) {
+ if (this.connection && callback) {
this.connection.off(methodName, callback);
+ } else if (this.connection) {
+ this.connection.off(methodName);
}
}
diff --git a/package-lock.json b/package-lock.json
index f4f995a..ed132a9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,11 +8,15 @@
"name": "colaflow-web",
"version": "0.1.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
@@ -24,6 +28,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.66.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@@ -295,6 +300,59 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
@@ -1270,6 +1328,12 @@
"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": {
"version": "1.1.3",
"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": {
"version": "1.2.3",
"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": {
"version": "1.1.1",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
@@ -6948,6 +7093,16 @@
"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": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
diff --git a/package.json b/package.json
index 6dd3507..34722ff 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,15 @@
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,css,md}\""
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@microsoft/signalr": "^9.0.6",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "^5.90.6",
"@tanstack/react-query-devtools": "^5.90.2",
@@ -26,6 +30,7 @@
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "^7.66.0",
+ "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"