feat(backlog): add TaskPanel with sortable rows and TaskDialog wiring
Reads selectedStoryId + tasksByStory from stores. DnD reorder via reorderTasksAction. Row click → ?editTask, + button → ?newTask&storyId. DemoTooltip on drag handles and + button. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
57e2d49949
commit
b96a285803
1 changed files with 196 additions and 0 deletions
196
components/backlog/task-panel.tsx
Normal file
196
components/backlog/task-panel.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTransition } from 'react'
|
||||
import {
|
||||
DndContext, DragEndEvent,
|
||||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates,
|
||||
useSortable, verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useSelectionStore } from '@/stores/selection-store'
|
||||
import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store'
|
||||
import { reorderTasksAction } from '@/actions/tasks'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { EmptyPanel } from './empty-panel'
|
||||
import { PRIORITY_BORDER } from './backlog-card'
|
||||
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 SortableTaskRow({
|
||||
task,
|
||||
isDemo,
|
||||
onClick,
|
||||
}: {
|
||||
task: BacklogTask
|
||||
isDemo: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: task.id })
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer',
|
||||
'bg-surface-container hover:bg-surface-container-high transition-colors',
|
||||
PRIORITY_BORDER[task.priority],
|
||||
isDragging && 'opacity-50 shadow-lg',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
disabled={isDemo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground cursor-grab disabled:opacity-30 disabled:cursor-not-allowed shrink-0"
|
||||
aria-label="Versleep om te herordenen"
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
</DemoTooltip>
|
||||
|
||||
<span className="flex-1 text-sm truncate">{task.title}</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded border shrink-0',
|
||||
STATUS_COLORS[task.status] ?? STATUS_COLORS.TO_DO,
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskPanelProps {
|
||||
productId: string
|
||||
isDemo: boolean
|
||||
closePath: string
|
||||
}
|
||||
|
||||
export function TaskPanel({ productId, isDemo, closePath }: TaskPanelProps) {
|
||||
const router = useRouter()
|
||||
const [, startTransition] = useTransition()
|
||||
const selectedStoryId = useSelectionStore((s) => s.selectedStoryId)
|
||||
const tasksByStory = useBacklogStore((s) => s.tasksByStory)
|
||||
|
||||
const tasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
if (!selectedStoryId) return
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const ids = tasks!.map((t) => t.id)
|
||||
const from = ids.indexOf(active.id as string)
|
||||
const to = ids.indexOf(over.id as string)
|
||||
if (from === -1 || to === -1) return
|
||||
|
||||
const reordered = [...ids]
|
||||
reordered.splice(from, 1)
|
||||
reordered.splice(to, 0, active.id as string)
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await reorderTasksAction(selectedStoryId, reordered)
|
||||
if (result?.error) toast.error(result.error)
|
||||
})
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-surface-container-low shrink-0">
|
||||
<h2 className="text-sm font-medium">Taken</h2>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isDemo || !selectedStoryId}
|
||||
onClick={() => {
|
||||
if (!selectedStoryId) return
|
||||
router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`)
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
Nieuwe taak
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tasks === null) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{header}
|
||||
<EmptyPanel message="Selecteer een story om de taken te bekijken." />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{header}
|
||||
<EmptyPanel
|
||||
message="Nog geen taken voor deze story."
|
||||
action={{
|
||||
label: 'Nieuwe taak',
|
||||
onClick: () => router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`),
|
||||
disabled: isDemo,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{header}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||
{tasks.map((task) => (
|
||||
<SortableTaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
isDemo={isDemo}
|
||||
onClick={() => router.push(`${closePath}?editTask=${task.id}`)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue