228 lines
6.5 KiB
TypeScript
228 lines
6.5 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
closestCenter,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
rectSortingStrategy,
|
|
arrayMove,
|
|
sortableKeyboardCoordinates,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { toast } from 'sonner'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
|
import { useSelectionStore } from '@/stores/selection-store'
|
|
import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store'
|
|
import { reorderTasksAction } from '@/actions/tasks'
|
|
import { BacklogCard } from './backlog-card'
|
|
import { debugProps } from '@/lib/debug'
|
|
import { EmptyPanel } from './empty-panel'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
|
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
|
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
|
|
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
|
}
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
TO_DO: 'To Do',
|
|
IN_PROGRESS: 'Bezig',
|
|
REVIEW: 'Review',
|
|
DONE: 'Klaar',
|
|
}
|
|
|
|
function SortableTaskCard({
|
|
task,
|
|
isDemo,
|
|
onClick,
|
|
}: {
|
|
task: BacklogTask
|
|
isDemo: boolean
|
|
onClick: () => void
|
|
}) {
|
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
|
useSortable({ id: task.id })
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}
|
|
|
|
return (
|
|
<BacklogCard
|
|
ref={setNodeRef}
|
|
style={style}
|
|
{...attributes}
|
|
{...(isDemo ? {} : listeners)}
|
|
title={task.title}
|
|
priority={task.priority}
|
|
isDragging={isDragging}
|
|
onClick={onClick}
|
|
badge={
|
|
<Badge
|
|
className={cn(
|
|
'text-[10px] px-1.5 py-0 border',
|
|
STATUS_COLORS[task.status] ?? STATUS_COLORS.TO_DO,
|
|
)}
|
|
>
|
|
{STATUS_LABELS[task.status] ?? task.status}
|
|
</Badge>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface TaskPanelProps {
|
|
productId: string
|
|
isDemo: boolean
|
|
closePath: string
|
|
}
|
|
|
|
export function TaskPanel({ isDemo, closePath }: TaskPanelProps) {
|
|
const router = useRouter()
|
|
const [, startTransition] = useTransition()
|
|
const selectedStoryId = useSelectionStore((s) => s.selectedStoryId)
|
|
const tasksByStory = useBacklogStore((s) => s.tasksByStory)
|
|
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
|
const [localOrder, setLocalOrder] = useState<string[] | null>(null)
|
|
|
|
const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null
|
|
|
|
// Merge local order with rawTasks for optimistic reorder
|
|
const tasks: BacklogTask[] | null = rawTasks === null
|
|
? null
|
|
: localOrder
|
|
? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[]
|
|
: rawTasks
|
|
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
)
|
|
|
|
function handleDragStart(event: DragStartEvent) {
|
|
setActiveDragId(event.active.id as string)
|
|
}
|
|
|
|
function handleDragEnd(event: DragEndEvent) {
|
|
setActiveDragId(null)
|
|
if (!selectedStoryId || !tasks) return
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
|
|
const ids = tasks.map((t) => t.id)
|
|
const oldIndex = ids.indexOf(active.id as string)
|
|
const newIndex = ids.indexOf(over.id as string)
|
|
if (oldIndex === -1 || newIndex === -1) return
|
|
|
|
const newOrder = arrayMove(ids, oldIndex, newIndex)
|
|
setLocalOrder(newOrder)
|
|
|
|
startTransition(async () => {
|
|
const result = await reorderTasksAction(selectedStoryId, newOrder)
|
|
if (result?.error) {
|
|
setLocalOrder(null)
|
|
toast.error(result.error)
|
|
}
|
|
})
|
|
}
|
|
|
|
const navActions = (
|
|
<DemoTooltip show={isDemo}>
|
|
<Button
|
|
size="sm"
|
|
className="h-7 text-xs"
|
|
disabled={isDemo || !selectedStoryId}
|
|
onClick={() => {
|
|
if (!selectedStoryId) return
|
|
router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`)
|
|
}}
|
|
>
|
|
+ Nieuwe taak
|
|
</Button>
|
|
</DemoTooltip>
|
|
)
|
|
|
|
const dp = debugProps('task-panel', 'TaskPanel', 'components/backlog/task-panel.tsx')
|
|
|
|
if (tasks === null) {
|
|
return (
|
|
<div className="flex flex-col h-full" {...dp}>
|
|
<PanelNavBar title="Taken" actions={navActions} />
|
|
<EmptyPanel message="Selecteer een story om de taken te bekijken." />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (tasks.length === 0) {
|
|
return (
|
|
<div className="flex flex-col h-full" {...dp}>
|
|
<PanelNavBar title="Taken" actions={navActions} />
|
|
<EmptyPanel
|
|
message="Nog geen taken voor deze story."
|
|
action={{
|
|
label: 'Nieuwe taak',
|
|
onClick: () => router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`),
|
|
disabled: isDemo,
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null
|
|
|
|
return (
|
|
<div className="flex flex-col h-full" {...dp}>
|
|
<PanelNavBar title="Taken" actions={navActions} />
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
<DndContext
|
|
id="task-panel"
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{tasks.map((task) => (
|
|
<SortableTaskCard
|
|
key={task.id}
|
|
task={task}
|
|
isDemo={isDemo}
|
|
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
|
|
<DragOverlay>
|
|
{activeTask && (
|
|
<BacklogCard
|
|
title={activeTask.title}
|
|
priority={activeTask.priority}
|
|
className="border-primary shadow-xl opacity-90"
|
|
/>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|