Scrum4Me/components/sprint/sprint-board-client.tsx
Janpeter Visser 8877ea469d
feat(M14): 3-pane backlog — generic SplitPane, BacklogStore, SSE realtime, card-grid TaskPanel (#22)
* feat(split-pane): refactor to generic n-pane SplitPane with cookie persistence

New API: panes[], defaultSplit[], cookieKey, tabLabels. Supports arbitrary
number of panes with n-1 draggable dividers and JSON cookie persistence.
Replaces TriplePane; mobile renders tabs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(split-pane): migrate callers to new panes[] API

Backlog page and sprint board now use generic SplitPane.
TriplePane removed; sprint board uses 3-pane with defaultSplit=[28,35,37].

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(split-pane): add unit tests for 2/3-pane, cookie-restore, mobile tabs

Added jsdom + @testing-library/react devDeps for component testing.
7 cases: render, divider count, cookie restore, invalid cookie fallback,
mobile tab render/switch, and no-dividers-on-mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): add BacklogStore Zustand store with applyChange reducer

State: pbis, storiesByPbi, tasksByStory. setInitialData for server
hydration; applyChange(entity, op, data) handles I/U/D for SSE events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): server-fetch tasks + hydrate BacklogStore on page load

Page now fetches tasks parallel to stories and groups by story_id.
BacklogHydrationWrapper calls setInitialData on mount so the store
is ready for downstream SSE consumers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): add EmptyPanel shared component, replace inline empty states

EmptyPanel takes title?, message, and optional action with DemoTooltip.
Replaces duplicate inline empty-state markup in pbi-list and story-panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* 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>

* feat(backlog): wire TaskPanel + TaskDialog into backlog page

3-pane SplitPane [20,45,35]. searchParams for newTask/editTask.
TaskDialog and EditTaskLoader render on ?newTask and ?editTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(backlog): add TaskPanel tests for render states and click handlers

7 cases: no-story empty, no-tasks empty+action, tasks render, + button
router.push, row click router.push, demo disabled button, demo disabled handles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): migrate PbiList to store-driven via useBacklogStore

Removes pbis prop; reads from useBacklogStore(s => s.pbis) so SSE
updates reflect in real-time without prop drilling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(backlog): migrate StoryPanel to store-driven + selectStory on click

Removes storiesByPbi prop; reads from useBacklogStore. Card click now
dispatches selectStory(id) + shows isSelected highlight. Edit moved to
inline pencil button. page.tsx drops pbis/storiesByPbi props.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(backlog): add 3-pane integration tests for click-cascade flow

Covers: empty states, PBI→stories, story→tasks, cascade-reset,
isSelected highlight. localStorage mocked for sort-mode persistence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1115): SSE backlog realtime — endpoint, hook, hydration mount, tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1116): mobile auto-switch tabs + back button in BacklogSplitPane

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1116): update functional-spec (3-pane backlog + mobile) and architecture (backlog SSE + backlog-store)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1117): TaskPanel card-grid — BacklogCard + rectSortingStrategy, tests updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): correct PbiStatusApi type and remove duplicate mock keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 18:16:07 +02:00

255 lines
9.1 KiB
TypeScript

'use client'
import { useState, useEffect, 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 { 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 type { Task } from './task-list'
import { useSprintStore } from '@/stores/sprint-store'
import {
addStoryToSprintAction,
removeStoryFromSprintAction,
reorderSprintStoriesAction,
} from '@/actions/sprints'
interface SprintBoardClientProps {
productId: string
sprintId: string
stories: SprintStory[]
pbisWithStories: PbiWithStories[]
sprintStoryIdList: string[]
tasksByStory: Record<string, Task[]>
isDemo: boolean
currentUserId: string
members: ProductMember[]
}
export function SprintBoardClient({
productId,
sprintId,
stories,
pbisWithStories,
sprintStoryIdList,
tasksByStory,
isDemo,
currentUserId,
members,
}: SprintBoardClientProps) {
const [sprintStories, setSprintStories] = useState<SprintStory[]>(stories)
const [sprintStoryIds, setSprintStoryIds] = useState<Set<string>>(() => new Set(sprintStoryIdList))
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null)
const {
sprintStoryOrder,
initSprint,
addStoryToSprint,
removeStoryFromSprint,
reorderSprintStories,
rollbackSprint,
} = useSprintStore()
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
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 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()
const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id)
// Drag from left (product backlog) → 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) 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
}
// Drag from middle (sprint backlog) → left (product backlog) → remove
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)
if (selectedStoryId === activeId) setSelectedStoryId(null)
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 (middle panel)
if (activeId !== overId && !activeId.startsWith('pb:')) {
const prevOrder = [...order]
const newOrder = order.includes(overId)
? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId))
: [...order.filter(id => id !== activeId), activeId]
reorderSprintStories(sprintId, newOrder)
startTransition(async () => {
const result = await reorderSprintStoriesAction(sprintId, newOrder)
if (!result.success) {
rollbackSprint(sprintId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
}
}
function handleAdd(storyId: string) {
if (sprintStoryIds.has(storyId)) return
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')
}
})
}
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
setSprintStories(prev =>
prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s)
)
}
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)
if (selectedStoryId === storyId) setSelectedStoryId(null)
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')
}
})
}
const selectedTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : []
return (
<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}
onAdd={handleAdd}
/>,
<SprintBacklogLeft
key="sprint"
sprintId={sprintId}
stories={sprintStories}
isDemo={isDemo}
onRemove={handleRemove}
onSelect={setSelectedStoryId}
selectedStoryId={selectedStoryId}
currentUserId={currentUserId}
productId={productId}
members={members}
onAssigneeChange={handleAssigneeChange}
/>,
selectedStoryId ? (
<TaskList
key="tasks"
storyId={selectedStoryId}
storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null}
sprintId={sprintId}
productId={productId}
tasks={selectedTasks}
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">
<span className="text-muted-foreground select-none"></span>
<span className="truncate flex-1">{activeDragStory.title}</span>
</div>
)}
</DragOverlay>
</DndContext>
)
}