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:
@@ -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">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Welcome to ColaFlow - Your AI-powered project management system
|
Welcome back! Here's an overview of your projects.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
New Project
|
||||||
|
</Button>
|
||||||
|
</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 ? (
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold">{stats.totalProjects}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Active projects in your workspace
|
Projects in your workspace
|
||||||
</p>
|
</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>
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-2xl font-bold text-green-600">{stats.activeProjects}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Currently in progress
|
Currently in progress
|
||||||
</p>
|
</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>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<CardTitle>Recent Projects</CardTitle>
|
<CardTitle>Recent Projects</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Your recently created projects</CardDescription>
|
||||||
Your most recently updated projects
|
</div>
|
||||||
</CardDescription>
|
<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>
|
|
||||||
<h3 className="font-medium">{project.name}</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">{project.key}</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
project.status === 'Active'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold">{project.name}</h3>
|
||||||
|
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
||||||
{project.status}
|
{project.status}
|
||||||
</span>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{project.key} • {project.description || 'No description'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{new Date(project.createdAt).toLocaleDateString()}
|
||||||
</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">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
No projects yet. Create your first project to get started.
|
No projects yet. Create your first project to get started.
|
||||||
</p>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
114
app/(dashboard)/projects/[id]/kanban/page.tsx
Normal file
114
app/(dashboard)/projects/[id]/kanban/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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';
|
'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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
>
|
||||||
|
{issues.map((issue) => (
|
||||||
|
<IssueCard key={issue.id} issue={issue} />
|
||||||
))}
|
))}
|
||||||
{column.tasks.length === 0 && (
|
</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
160
components/ui/select.tsx
Normal 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,
|
||||||
|
};
|
||||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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 };
|
||||||
24
components/ui/textarea.tsx
Normal file
24
components/ui/textarea.tsx
Normal 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 };
|
||||||
@@ -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
84
lib/api/issues.ts
Normal 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
102
lib/hooks/use-issues.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
155
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user