Scrum4Me/components/sprint/sprint-board-client.tsx
Janpeter Visser 3b5cee823c
Load/render workspace alignment (#182)
* docs: plan load render workspace alignment

* fix: normalize workspace status hydration

* fix: avoid duplicate backlog hydration load

* refactor: use sprint store active story

* refactor: migrate solo to workspace store

* chore: stabilize verification ignores
2026-05-10 07:34:58 +02:00

277 lines
9.4 KiB
TypeScript

'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<SprintStory | null>(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 (
<div {...debugProps('sprint-board-client')} className="contents">
<DndContext
id="sprint-board"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SplitPane
cookieKey={`sprint-${productId}`}
defaultSplit={[28, 35, 37]}
tabLabels={['Backlog', 'Sprint', 'Taken']}
panes={[
<SprintBacklogRight
key="backlog"
pbisWithStories={pbisWithStories}
sprintStoryIds={sprintStoryIds}
isDemo={isDemo}
productId={productId}
onAdd={(storyId) => {
const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId)
if (storyData) handleAdd(storyId, storyData)
}}
/>,
<SprintBacklogLeft
key="sprint"
sprintId={sprintId}
isDemo={isDemo}
onRemove={handleRemove}
onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)}
selectedStoryId={selectedStoryId}
currentUserId={currentUserId}
productId={productId}
members={members}
onAssigneeChange={handleAssigneeChange}
/>,
selectedStoryId ? (
<TaskList
key="tasks"
sprintId={sprintId}
productId={productId}
isDemo={isDemo}
/>
) : (
<div key="tasks-empty" className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Selecteer een story om de taken te bekijken.</p>
</div>
),
]}
/>
<DragOverlay>
{activeDragStory && (
<div className="flex items-center gap-3 px-6 py-2 bg-popover border border-primary rounded shadow-lg text-sm opacity-95 w-72" data-debug-id="sprint-board-client__drag-overlay">
<span className="text-muted-foreground select-none"></span>
<span className="truncate flex-1">{activeDragStory.title}</span>
</div>
)}
</DragOverlay>
</DndContext>
</div>
)
}