Kanban
This commit is contained in:
@@ -21,15 +21,19 @@ const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
@@ -41,10 +45,10 @@ const CardTitle = React.forwardRef<
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
|
||||
145
frontend/src/components/ui/shadcn-io/kanban/index.tsx
Normal file
145
frontend/src/components/ui/shadcn-io/kanban/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DndContext,
|
||||
rectIntersection,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type { DragEndEvent } from '@dnd-kit/core';
|
||||
|
||||
export type Status = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
id: string;
|
||||
name: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
status: Status;
|
||||
};
|
||||
|
||||
export type KanbanBoardProps = {
|
||||
id: Status['id'];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
||||
const { isOver, setNodeRef } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
|
||||
isOver ? 'outline-primary' : 'outline-transparent',
|
||||
className
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
|
||||
index: number;
|
||||
parent: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanCard = ({
|
||||
id,
|
||||
name,
|
||||
index,
|
||||
parent,
|
||||
children,
|
||||
className,
|
||||
}: KanbanCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id,
|
||||
data: { index, parent },
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'rounded-md p-3 shadow-sm',
|
||||
isDragging && 'cursor-grabbing',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translateX(${transform.x}px) translateY(${transform.y}px)`
|
||||
: 'none',
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export type KanbanCardsProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
|
||||
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
|
||||
);
|
||||
|
||||
export type KanbanHeaderProps =
|
||||
| {
|
||||
children: ReactNode;
|
||||
}
|
||||
| {
|
||||
name: Status['name'];
|
||||
color: Status['color'];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanHeader = (props: KanbanHeaderProps) =>
|
||||
'children' in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: props.color }}
|
||||
/>
|
||||
<p className="m-0 font-semibold text-sm">{props.name}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type KanbanProviderProps = {
|
||||
children: ReactNode;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanProvider = ({
|
||||
children,
|
||||
onDragEnd,
|
||||
className,
|
||||
}: KanbanProviderProps) => (
|
||||
<DndContext collisionDetection={rectIntersection} onDragEnd={onDragEnd}>
|
||||
<div
|
||||
className={cn('grid w-full auto-cols-fr grid-flow-col gap-4', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,6 +26,14 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
||||
import { getAuthHeaders } from '@/lib/auth'
|
||||
import {
|
||||
KanbanProvider,
|
||||
KanbanBoard,
|
||||
KanbanHeader,
|
||||
KanbanCards,
|
||||
KanbanCard,
|
||||
type DragEndEvent
|
||||
} from '@/components/ui/shadcn-io/kanban'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
@@ -52,12 +59,7 @@ interface ApiResponse<T> {
|
||||
message: string | null
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
Todo: 'bg-slate-100 text-slate-800',
|
||||
InProgress: 'bg-blue-100 text-blue-800',
|
||||
Done: 'bg-green-100 text-green-800',
|
||||
Cancelled: 'bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
|
||||
const statusLabels = {
|
||||
Todo: 'To Do',
|
||||
@@ -66,6 +68,13 @@ const statusLabels = {
|
||||
Cancelled: 'Cancelled'
|
||||
}
|
||||
|
||||
const statusBoardColors = {
|
||||
Todo: '#64748b',
|
||||
InProgress: '#3b82f6',
|
||||
Done: '#22c55e',
|
||||
Cancelled: '#ef4444'
|
||||
}
|
||||
|
||||
export function ProjectTasks() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -219,6 +228,68 @@ export function ProjectTasks() {
|
||||
setIsEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (!over || !active.data.current) return
|
||||
|
||||
const taskId = active.id as string
|
||||
const newStatus = over.id as Task['status']
|
||||
const task = tasks.find(t => t.id === taskId)
|
||||
|
||||
if (!task || task.status === newStatus) return
|
||||
|
||||
// Optimistically update the UI immediately
|
||||
const previousStatus = task.status
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === taskId ? { ...t, status: newStatus } : t
|
||||
))
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: newStatus
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Revert the optimistic update if the API call failed
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === taskId ? { ...t, status: previousStatus } : t
|
||||
))
|
||||
setError('Failed to update task status')
|
||||
}
|
||||
} catch (err) {
|
||||
// Revert the optimistic update if the API call failed
|
||||
setTasks(prev => prev.map(t =>
|
||||
t.id === taskId ? { ...t, status: previousStatus } : t
|
||||
))
|
||||
setError('Failed to update task status')
|
||||
}
|
||||
}
|
||||
|
||||
const groupTasksByStatus = () => {
|
||||
const groups: Record<Task['status'], Task[]> = {
|
||||
Todo: [],
|
||||
InProgress: [],
|
||||
Done: [],
|
||||
Cancelled: []
|
||||
}
|
||||
|
||||
tasks.forEach(task => {
|
||||
groups[task.status].push(task)
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Loading tasks...</div>
|
||||
}
|
||||
@@ -295,7 +366,7 @@ export function ProjectTasks() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Tasks Grid */}
|
||||
{/* Tasks View */}
|
||||
{tasks.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
@@ -310,52 +381,81 @@ export function ProjectTasks() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{tasks.map((task) => (
|
||||
<Card key={task.id} className="relative">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
||||
<Badge
|
||||
className={`mt-2 ${statusColors[task.status]}`}
|
||||
variant="secondary"
|
||||
>
|
||||
{statusLabels[task.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(task)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteTask(task.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{task.description && (
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
<KanbanProvider onDragEnd={handleDragEnd}>
|
||||
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
||||
<KanbanBoard key={status} id={status as Task['status']}>
|
||||
<KanbanHeader
|
||||
name={statusLabels[status as Task['status']]}
|
||||
color={statusBoardColors[status as Task['status']]}
|
||||
/>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task, index) => (
|
||||
<KanbanCard
|
||||
key={task.id}
|
||||
id={task.id}
|
||||
name={task.title}
|
||||
index={index}
|
||||
parent={status}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div
|
||||
className="flex-1 cursor-pointer pr-2"
|
||||
onClick={() => openEditDialog(task)}
|
||||
>
|
||||
<h4 className="font-medium text-sm">
|
||||
{task.title}
|
||||
</h4>
|
||||
</div>
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEditDialog(task)}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => deleteTask(task.id)}
|
||||
className="text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
{task.description && (
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={() => openEditDialog(task)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{task.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</KanbanCard>
|
||||
))}
|
||||
</KanbanCards>
|
||||
</KanbanBoard>
|
||||
))}
|
||||
</div>
|
||||
</KanbanProvider>
|
||||
)}
|
||||
|
||||
{/* Edit Task Dialog */}
|
||||
|
||||
Reference in New Issue
Block a user