Kanban
This commit is contained in:
56
frontend/package-lock.json
generated
56
frontend/package-lock.json
generated
@@ -8,8 +8,11 @@
|
|||||||
"name": "bloop-frontend",
|
"name": "bloop-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-portal": "^1.1.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
@@ -356,6 +359,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/modifiers": {
|
||||||
|
"version": "9.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
|
||||||
|
"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/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-portal": "^1.1.9",
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@radix-ui/react-separator": "^1.1.7",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
|||||||
@@ -21,15 +21,19 @@ const CardHeader = React.forwardRef<
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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"
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLHeadingElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<h3
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-semibold leading-none tracking-tight",
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
@@ -41,10 +45,10 @@ const CardTitle = React.forwardRef<
|
|||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLParagraphElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...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 { 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 { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -27,6 +26,14 @@ import {
|
|||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
import { ArrowLeft, Plus, MoreHorizontal, Trash2, Edit } from 'lucide-react'
|
||||||
import { getAuthHeaders } from '@/lib/auth'
|
import { getAuthHeaders } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
KanbanProvider,
|
||||||
|
KanbanBoard,
|
||||||
|
KanbanHeader,
|
||||||
|
KanbanCards,
|
||||||
|
KanbanCard,
|
||||||
|
type DragEndEvent
|
||||||
|
} from '@/components/ui/shadcn-io/kanban'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: string
|
id: string
|
||||||
@@ -52,12 +59,7 @@ interface ApiResponse<T> {
|
|||||||
message: string | null
|
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 = {
|
const statusLabels = {
|
||||||
Todo: 'To Do',
|
Todo: 'To Do',
|
||||||
@@ -66,6 +68,13 @@ const statusLabels = {
|
|||||||
Cancelled: 'Cancelled'
|
Cancelled: 'Cancelled'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusBoardColors = {
|
||||||
|
Todo: '#64748b',
|
||||||
|
InProgress: '#3b82f6',
|
||||||
|
Done: '#22c55e',
|
||||||
|
Cancelled: '#ef4444'
|
||||||
|
}
|
||||||
|
|
||||||
export function ProjectTasks() {
|
export function ProjectTasks() {
|
||||||
const { projectId } = useParams<{ projectId: string }>()
|
const { projectId } = useParams<{ projectId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -219,6 +228,68 @@ export function ProjectTasks() {
|
|||||||
setIsEditDialogOpen(true)
|
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) {
|
if (loading) {
|
||||||
return <div className="text-center py-8">Loading tasks...</div>
|
return <div className="text-center py-8">Loading tasks...</div>
|
||||||
}
|
}
|
||||||
@@ -295,7 +366,7 @@ export function ProjectTasks() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Tasks Grid */}
|
{/* Tasks View */}
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="text-center py-8">
|
<CardContent className="text-center py-8">
|
||||||
@@ -310,52 +381,81 @@ export function ProjectTasks() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<KanbanProvider onDragEnd={handleDragEnd}>
|
||||||
{tasks.map((task) => (
|
{Object.entries(groupTasksByStatus()).map(([status, statusTasks]) => (
|
||||||
<Card key={task.id} className="relative">
|
<KanbanBoard key={status} id={status as Task['status']}>
|
||||||
<CardHeader className="pb-3">
|
<KanbanHeader
|
||||||
<div className="flex items-start justify-between">
|
name={statusLabels[status as Task['status']]}
|
||||||
<div className="flex-1">
|
color={statusBoardColors[status as Task['status']]}
|
||||||
<CardTitle className="text-lg">{task.title}</CardTitle>
|
/>
|
||||||
<Badge
|
<KanbanCards>
|
||||||
className={`mt-2 ${statusColors[task.status]}`}
|
{statusTasks.map((task, index) => (
|
||||||
variant="secondary"
|
<KanbanCard
|
||||||
>
|
key={task.id}
|
||||||
{statusLabels[task.status]}
|
id={task.id}
|
||||||
</Badge>
|
name={task.title}
|
||||||
</div>
|
index={index}
|
||||||
<DropdownMenu>
|
parent={status}
|
||||||
<DropdownMenuTrigger asChild>
|
>
|
||||||
<Button variant="ghost" size="sm">
|
<div className="space-y-2">
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
<div className="flex items-start justify-between">
|
||||||
</Button>
|
<div
|
||||||
</DropdownMenuTrigger>
|
className="flex-1 cursor-pointer pr-2"
|
||||||
<DropdownMenuContent align="end">
|
onClick={() => openEditDialog(task)}
|
||||||
<DropdownMenuItem onClick={() => openEditDialog(task)}>
|
>
|
||||||
<Edit className="h-4 w-4 mr-2" />
|
<h4 className="font-medium text-sm">
|
||||||
Edit
|
{task.title}
|
||||||
</DropdownMenuItem>
|
</h4>
|
||||||
<DropdownMenuItem
|
</div>
|
||||||
onClick={() => deleteTask(task.id)}
|
<div
|
||||||
className="text-red-600"
|
className="flex-shrink-0"
|
||||||
>
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<Trash2 className="h-4 w-4 mr-2" />
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
Delete
|
onClick={(e) => e.stopPropagation()}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
</DropdownMenuContent>
|
<DropdownMenu>
|
||||||
</DropdownMenu>
|
<DropdownMenuTrigger asChild>
|
||||||
</div>
|
<Button
|
||||||
</CardHeader>
|
variant="ghost"
|
||||||
{task.description && (
|
size="sm"
|
||||||
<CardContent>
|
className="h-8 w-8 p-0 hover:bg-gray-100"
|
||||||
<p className="text-sm text-muted-foreground">
|
>
|
||||||
{task.description}
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</p>
|
</Button>
|
||||||
</CardContent>
|
</DropdownMenuTrigger>
|
||||||
)}
|
<DropdownMenuContent align="end">
|
||||||
</Card>
|
<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 */}
|
{/* Edit Task Dialog */}
|
||||||
|
|||||||
Reference in New Issue
Block a user