'use client' import { useState, useTransition } from 'react' import { DndContext, DragEndEvent, DragStartEvent, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { SplitPane } from '@/components/split-pane/split-pane' import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog' import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog' import { TaskList } from './task-list' import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types' import { addStoryToSprintAction, removeStoryFromSprintAction, reorderSprintStoriesAction, } from '@/actions/sprints' import { debugProps } from '@/lib/debug' interface SprintBoardClientProps { productId: string sprintId: string pbisWithStories: PbiWithStories[] isDemo: boolean currentUserId: string members: ProductMember[] } function toWorkspaceStory(story: SprintStory, sprintId: string): SprintWorkspaceStory { return { id: story.id, code: story.code, title: story.title, description: story.description, acceptance_criteria: story.acceptance_criteria, priority: story.priority, sort_order: story.sort_order, status: story.status, pbi_id: story.pbi_id, sprint_id: sprintId, created_at: story.created_at, taskCount: story.taskCount, doneCount: story.doneCount, assignee_id: story.assignee_id, assignee_username: story.assignee_username, } } export function SprintBoardClient({ productId, sprintId, pbisWithStories, isDemo, currentUserId, members, }: SprintBoardClientProps) { const sprintStories = useSprintWorkspaceStore( useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), ) const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId) const sprintStoryIds = new Set(sprintStories.map(s => s.id)) const [activeDragStory, setActiveDragStory] = useState(null) const [, startTransition] = useTransition() const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) function handleDragStart(event: DragStartEvent) { const id = event.active.id.toString() if (id.startsWith('pb:')) { const story = pbisWithStories.flatMap(p => p.stories).find(s => s.id === id.slice(3)) setActiveDragStory(story ?? null) } } function handleDragEnd(event: DragEndEvent) { setActiveDragStory(null) const { active, over } = event if (!over) return const activeId = active.id.toString() const overId = over.id.toString() // Drag from product backlog (left) → add to sprint (middle) 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) handleAdd(storyId, storyData) } return } // Drag from sprint (middle) → product backlog (left) → remove if (overId === 'backlog-zone') { handleRemove(activeId) return } // Reorder within sprint if (activeId !== overId && !activeId.startsWith('pb:')) { handleReorder(activeId, overId) } } function handleAdd(storyId: string, storyData: SprintStory) { if (sprintStoryIds.has(storyId)) return const store = useSprintWorkspaceStore.getState() const prevStory = store.entities.storiesById[storyId] const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] useSprintWorkspaceStore.setState((s) => { s.entities.storiesById[storyId] = toWorkspaceStory(storyData, sprintId) const list = s.relations.storyIdsBySprint[sprintId] ?? [] if (!list.includes(storyId)) list.push(storyId) s.relations.storyIdsBySprint[sprintId] = list }) startTransition(async () => { const result = await addStoryToSprintAction(sprintId, storyId) if (!result.success) { useSprintWorkspaceStore.setState((s) => { if (prevStory === undefined) { delete s.entities.storiesById[storyId] } else { s.entities.storiesById[storyId] = prevStory } s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds }) toast.error(result.error ?? 'Toevoegen mislukt') } }) } function handleRemove(storyId: string) { const store = useSprintWorkspaceStore.getState() const prevStory = store.entities.storiesById[storyId] const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] useSprintWorkspaceStore.setState((s) => { const list = s.relations.storyIdsBySprint[sprintId] if (list) { s.relations.storyIdsBySprint[sprintId] = list.filter((id) => id !== storyId) } const story = s.entities.storiesById[storyId] if (story) story.sprint_id = null }) if (selectedStoryId === storyId) { useSprintWorkspaceStore.getState().setActiveStory(null) } startTransition(async () => { const result = await removeStoryFromSprintAction(storyId) if (!result.success) { useSprintWorkspaceStore.setState((s) => { if (prevStory) s.entities.storiesById[storyId] = prevStory s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds }) toast.error('Verwijderen mislukt') } }) } function handleReorder(activeId: string, overId: string) { const store = useSprintWorkspaceStore.getState() const order = store.relations.storyIdsBySprint[sprintId] ?? [] const prevOrder = [...order] const newOrder = order.includes(overId) ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) : [...order.filter(id => id !== activeId), activeId] const mutationId = store.applyOptimisticMutation({ kind: 'sprint-story-order', sprintId, prevStoryIds: prevOrder, }) useSprintWorkspaceStore.setState((s) => { s.relations.storyIdsBySprint[sprintId] = newOrder }) startTransition(async () => { const result = await reorderSprintStoriesAction(sprintId, newOrder) const st = useSprintWorkspaceStore.getState() if (result.success) { st.settleMutation(mutationId) } else { st.rollbackMutation(mutationId) toast.error('Volgorde opslaan mislukt') } }) } function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { useSprintWorkspaceStore.setState((s) => { const story = s.entities.storiesById[storyId] if (story) { story.assignee_id = assigneeId story.assignee_username = assigneeUsername } }) } return (
{ const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) if (storyData) handleAdd(storyId, storyData) }} />, useSprintWorkspaceStore.getState().setActiveStory(storyId)} selectedStoryId={selectedStoryId} currentUserId={currentUserId} productId={productId} members={members} onAssigneeChange={handleAssigneeChange} />, selectedStoryId ? ( ) : (

Selecteer een story om de taken te bekijken.

), ]} /> {activeDragStory && (
{activeDragStory.title}
)}
) }