Scrum4Me/components/backlog/story-panel.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

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition, useEffect } from 'react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
rectSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { useBacklogStore } from '@/stores/backlog-store'
import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog'
import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { cn } from '@/lib/utils'
type SortMode = 'priority' | 'code' | 'date'
const STATUS_COLORS: Record<string, string> = {
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
}
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_SPRINT: 'In Sprint',
DONE: 'Klaar',
}
export interface Story {
id: string
code: string | null
title: string
description: string | null
acceptance_criteria: string | null
priority: number
status: string
pbi_id: string
created_at: Date
}
interface StoryPanelProps {
productId: string
isDemo: boolean
}
// --- Sortable story block ---
function SortableStoryBlock({
story,
isSelected,
onSelect,
onEdit,
}: {
story: Story
isSelected: boolean
onSelect: () => void
onEdit: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: story.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={story.title}
code={story.code}
priority={story.priority}
isDragging={isDragging}
isSelected={isSelected}
onClick={onSelect}
badge={
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status}
</Badge>
}
actions={
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors"
aria-label="Story bewerken"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
}
/>
)
}
// --- Main component ---
export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore()
const storiesByPbi = useBacklogStore((s) => s.storiesByPbi)
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
const [filterStatus, setFilterStatus] = useState<string | null>(null)
const [filterPriority, setFilterPriority] = useState<number | null>(null)
const [sortMode, setSortMode] = useState<SortMode>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
})
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode])
const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : []
// Sync into store — use stable string dep to avoid infinite loop
const storyIdKey = rawStories.map(s => s.id).join(',')
useEffect(() => {
if (selectedPbiId) {
initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPbiId, storyIdKey])
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
const base = orderedStories
.filter(s => !filterStatus || s.status === filterStatus)
.filter(s => !filterPriority || s.priority === filterPriority)
const filtered = [...base].sort((a, b) => {
if (sortMode === 'code') {
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
}
if (sortMode === 'date') {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}
return a.priority !== b.priority ? a.priority - b.priority : 0
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id || !selectedPbiId) return
const activeStory = storyMap[active.id as string]
const overStory = storyMap[over.id as string]
if (!activeStory || !overStory) return
const prevOrder = [...order]
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
const newOrder = arrayMove([...order], oldIndex, newIndex)
reorderStories(selectedPbiId, newOrder)
const priorityChanged = activeStory.priority !== overStory.priority
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
if (!result.success) {
rollbackStories(selectedPbiId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
}
const hasActiveFilters = filterStatus !== null || filterPriority !== null
return (
<div className="flex flex-col h-full">
<PanelNavBar
title="Stories"
actions={
<>
{hasActiveFilters && (
<button onClick={() => { setFilterStatus(null); setFilterPriority(null) }} className="text-xs text-primary hover:underline">
Filter wissen ×
</button>
)}
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="priority">Prioriteit</SelectItem>
<SelectItem value="code">Code</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
<Select
value={filterStatus ?? 'all'}
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_SPRINT">In Sprint</SelectItem>
<SelectItem value="DONE">Klaar</SelectItem>
</SelectContent>
</Select>
{selectedPbiId && (
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
>
+ Story
</Button>
</DemoTooltip>
)}
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
{selectedPbiId === null ? (
<EmptyPanel message="Selecteer een PBI om de stories te bekijken." />
) : rawStories.length === 0 ? (
<EmptyPanel
message="Nog geen stories voor dit PBI."
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
/>
) : (
<DndContext
id="story-panel"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-3 gap-2">
{filtered.map(story => (
<SortableStoryBlock
key={story.id}
story={story}
isSelected={selectedStoryId === story.id}
onSelect={() => selectStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<BacklogCard
title={storyMap[activeDragId].title}
priority={storyMap[activeDragId].priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}