Scrum4Me/components/sprint/sprint-backlog-client.tsx
janpeter visser 0bf635eca1 feat: ST-612 drag-and-drop tussen Product Backlog en Sprint Backlog
Vervangt de '+ Sprint' knop door cross-panel drag-and-drop:
- Sleep story van rechts (PB) naar links (SB) om toe te voegen
- Sleep story van links (SB) naar rechts (PB) om te verwijderen
- Gedeelde DndContext in SprintBacklogClient voor beide panelen
- Visuele dropzone-highlight bij hoveren
- Optimistische UI-updates met rollback bij fouten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 17:00:25 +02:00

167 lines
5.6 KiB
TypeScript

'use client'
import { useState, useEffect, useTransition } from 'react'
import {
DndContext, DragEndEvent,
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
import { toast } from 'sonner'
import { SplitPane } from '@/components/split-pane/split-pane'
import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog'
import type { SprintStory, PbiWithStories } from './sprint-backlog'
import { useSprintStore } from '@/stores/sprint-store'
import {
addStoryToSprintAction,
removeStoryFromSprintAction,
reorderSprintStoriesAction,
} from '@/actions/sprints'
interface SprintBacklogClientProps {
productId: string
sprintId: string
stories: SprintStory[]
pbisWithStories: PbiWithStories[]
sprintStoryIdList: string[]
isDemo: boolean
}
export function SprintBacklogClient({
productId,
sprintId,
stories,
pbisWithStories,
sprintStoryIdList,
isDemo,
}: SprintBacklogClientProps) {
const [sprintStories, setSprintStories] = useState<SprintStory[]>(stories)
const [sprintStoryIds, setSprintStoryIds] = useState<Set<string>>(() => new Set(sprintStoryIdList))
const {
sprintStoryOrder,
initSprint,
addStoryToSprint,
removeStoryFromSprint,
reorderSprintStories,
rollbackSprint,
} = useSprintStore()
const [, startTransition] = useTransition()
useEffect(() => {
initSprint(sprintId, stories.map(s => s.id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sprintId])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over) return
const activeId = active.id.toString()
const overId = over.id.toString()
const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id)
// Dragged from right panel (pb: prefix) → add to sprint
if (activeId.startsWith('pb:')) {
const storyId = activeId.slice(3)
const droppingOnSprint =
overId === 'sprint-zone' ||
(!overId.startsWith('pb:') && overId !== 'backlog-zone')
if (droppingOnSprint && !sprintStoryIds.has(storyId)) {
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (!storyData) return
setSprintStoryIds(prev => new Set([...prev, storyId]))
setSprintStories(prev => [...prev, storyData])
addStoryToSprint(sprintId, storyId)
startTransition(async () => {
const result = await addStoryToSprintAction(sprintId, storyId)
if (!result.success) {
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
setSprintStories(prev => prev.filter(s => s.id !== storyId))
removeStoryFromSprint(sprintId, storyId)
toast.error(result.error ?? 'Toevoegen mislukt')
}
})
}
return
}
// Dragged from left panel to right panel → remove from sprint
if (overId === 'backlog-zone') {
const storyData = sprintStories.find(s => s.id === activeId)
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(activeId); return n })
setSprintStories(prev => prev.filter(s => s.id !== activeId))
removeStoryFromSprint(sprintId, activeId)
startTransition(async () => {
const result = await removeStoryFromSprintAction(activeId)
if (!result.success) {
if (storyData) {
setSprintStoryIds(prev => new Set([...prev, activeId]))
setSprintStories(prev => [...prev, storyData])
}
addStoryToSprint(sprintId, activeId)
toast.error('Verwijderen mislukt')
}
})
return
}
// Reorder within sprint
if (activeId !== overId && order.includes(overId)) {
const prevOrder = [...order]
const newOrder = arrayMove([...order], order.indexOf(activeId), order.indexOf(overId))
reorderSprintStories(sprintId, newOrder)
startTransition(async () => {
const result = await reorderSprintStoriesAction(sprintId, newOrder)
if (!result.success) {
rollbackSprint(sprintId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
}
}
function handleRemove(storyId: string) {
const storyData = sprintStories.find(s => s.id === storyId)
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
setSprintStories(prev => prev.filter(s => s.id !== storyId))
removeStoryFromSprint(sprintId, storyId)
startTransition(async () => {
const result = await removeStoryFromSprintAction(storyId)
if (!result.success) {
if (storyData) {
setSprintStoryIds(prev => new Set([...prev, storyId]))
setSprintStories(prev => [...prev, storyData])
}
addStoryToSprint(sprintId, storyId)
toast.error('Verwijderen mislukt')
}
})
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SplitPane
storageKey={`sprint-${productId}`}
left={
<SprintBacklogLeft
sprintId={sprintId}
stories={sprintStories}
isDemo={isDemo}
onRemove={handleRemove}
/>
}
right={
<SprintBacklogRight
pbisWithStories={pbisWithStories}
sprintStoryIds={sprintStoryIds}
isDemo={isDemo}
/>
}
/>
</DndContext>
)
}