Scrum4Me/components/sprint/sprint-backlog.tsx
Janpeter Visser ff22196714
Sprint: Stories en taken krijgen één voorspelbare volgorde gekoppeld aan hun code; drag-and-drop herordening voor stories/taken verdwijnt, priority wordt puur label. (#201)
* feat(code): add parseCodeNumber helper to lib/code.ts

Pure helper that extracts the trailing numeric sequence from a code string
(ST-007 → 7, T-42 → 42). Non-conforming codes fall back to Number.MAX_SAFE_INTEGER
so they sort to the end. Includes 5 unit tests.

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

* feat(tasks): add code field to BacklogTask type and all task selects

Adds `code: string | null` to BacklogTask interface and includes it in
all Prisma task.findMany selects (backlog API, stories tasks API, page
hydration routes). Updates coerceTaskPayload and test fixtures to match.

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

* feat(sort-order): derive story/task sort_order from parseCodeNumber(code)

All create paths (createStoryAction, saveTask, createTaskAction,
materializeIdeaPlanAction) and code-edit paths (updateStoryAction, saveTask
update) now set sort_order = parseCodeNumber(code) instead of last+1.
Removes stale last-record queries from create paths.

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

* fix(sort-order): decouple sprint membership actions from sort_order

createSprintAction and addStoryToSprintAction no longer write sort_order
when adding stories to a sprint. sort_order is derived from code via
parseCodeNumber, so membership should only set sprint_id + status.

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

* refactor(ordering): remove priority from all story/task orderBy

Story- en taak-ordering is nu puur sort_order asc (created_at als
tiebreaker). PBI-ordering (priority + sort_order) blijft ongewijzigd.

Gewijzigd: backlog/route, pbis/stories/route, claude-context/route,
next-story/route, workspace/route, tasks/route, sprint-runs (query +
in-memory sort), solo-workspace-server, page.tsx (app + mobile + sprint),
store compareStory, actions/sprints story-query, next-story test.

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

* refactor(dnd): remove drag-and-drop reorder for stories and tasks

- Remove reorderStoriesAction, reorderTasksAction, reorderSprintStoriesAction
- Delete REST route app/api/stories/[id]/tasks/reorder/route.ts
- Remove DnD from backlog story-panel and task-panel (flat list)
- Remove reorder-within-sprint branch from sprint-board-client handleDragEnd
- Switch SortableSprintRow to plain SprintRow using useDraggable (membership drag kept)
- Remove all DnD from task-list (status toggle + edit kept)
- Remove story-order/task-order/sprint-story-order/sprint-task-order mutation types and store handlers
- Remove related tests for deleted reorder route; fix sprint store tests

* feat(backlog): toon code-badge op backlog-taakkaarten

Geeft code={task.code} door aan <BacklogCard> in TaskCard (task-panel.tsx).
BacklogCard rendert de CodeBadge al conditionally — alleen de prop ontbrak.

* feat(migration): backfill story/task sort_order from code numeric suffix

One-time Prisma migration that sets sort_order = trailing numeric part
of code for all existing stories and tasks, consistent with
parseCodeNumber (fallback = Number.MAX_SAFE_INTEGER for non-conforming
codes). PBIs are intentionally excluded.

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

* docs+tests(sort-order): update for code-binding order on stories/tasks

- Rewrite docs/patterns/sort-order.md: float-insertion PBI only; story/task
  sort_order = parseCodeNumber(code), never drag/membership mutated
- Update plan-to-pbi-flow.md: sort_order auto, sprint_id param, priority=label
- Update make-plan.md: priority=label, array order = execution order
- Update rest-contract.md: fix sprint-tasks ordering, remove reorder endpoint
- Add ADR-0011: code is bindende volgordesleutel voor stories/taken
- Regenerate docs/INDEX.md via npm run docs
- Remove reorderStoriesAction/reorderTasksAction mocks from backlog tests
- Remove dnd-kit mocks from task-panel test (panel no longer uses dnd)
- Extend materializeIdeaPlanAction test: assert sort_order=parseCodeNumber(code)

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:02:36 +02:00

665 lines
25 KiB
TypeScript
Raw Permalink 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 { useMemo, useState, useTransition } from 'react'
import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react'
import { useDroppable, useDraggable } from '@dnd-kit/core'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import {
BacklogFilterPopover,
PRIORITY_LABELS as SHARED_PRIORITY_LABELS,
type SortDir,
} from '@/components/shared/backlog-filter-popover'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { UserAvatar } from '@/components/shared/user-avatar'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors'
import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories'
import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog'
import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog'
import type { PbiStatusApi } from '@/lib/task-status'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
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 SprintStory {
id: string
code: string | null
title: string
description: string | null
acceptance_criteria: string | null
pbi_id: string
sprint_id: string | null
created_at: Date
priority: number
sort_order: number
status: string
taskCount: number
doneCount: number
assignee_id: string | null
assignee_username: string | null
}
export interface ProductMember {
userId: string
username: string
}
export interface PbiWithStories {
id: string
code: string | null
title: string
priority: number
status: PbiStatusApi
description: string | null
stories: SprintStory[]
}
// --- Left panel: Sprint Backlog ---
function SprintRow({
story, isDemo, onRemove, onSelect, onEdit, isSelected,
currentUserId, productId, members, onAssigneeChange,
}: {
story: SprintStory
isDemo: boolean
onRemove: () => void
onSelect: () => void
onEdit: () => void
isSelected: boolean
currentUserId: string
productId: string
members: ProductMember[]
onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: story.id })
const style = { opacity: isDragging ? 0.4 : 1 }
const [, startTransition] = useTransition()
function handleClaim(e: React.MouseEvent) {
e.stopPropagation()
const me = members.find(m => m.userId === currentUserId)
onAssigneeChange(story.id, currentUserId, me?.username ?? null)
startTransition(async () => {
const result = await claimStoryAction(story.id, productId)
if (!result.success) {
onAssigneeChange(story.id, story.assignee_id, story.assignee_username)
toast.error(result.error ?? 'Claimen mislukt')
} else {
toast.success('Story geclaimd')
}
})
}
function handleUnclaim(e: React.MouseEvent) {
e.stopPropagation()
onAssigneeChange(story.id, null, null)
startTransition(async () => {
const result = await unclaimStoryAction(story.id, productId)
if (!result.success) {
onAssigneeChange(story.id, story.assignee_id, story.assignee_username)
toast.error(result.error ?? 'Teruggeven mislukt')
}
})
}
function handleReassign(e: React.MouseEvent, targetUserId: string, targetUsername: string) {
e.stopPropagation()
onAssigneeChange(story.id, targetUserId, targetUsername)
startTransition(async () => {
const result = await reassignStoryAction(story.id, productId, targetUserId)
if (!result.success) {
onAssigneeChange(story.id, story.assignee_id, story.assignee_username)
toast.error(result.error ?? 'Toewijzen mislukt')
} else {
toast.success(`Toegewezen aan ${targetUsername}`)
}
})
}
return (
<div
ref={setNodeRef}
style={style}
onClick={onSelect}
className="group px-2 py-1 cursor-pointer"
>
<div className={cn(
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors',
PRIORITY_BORDER[story.priority],
isSelected
? 'bg-primary-container border-primary text-primary-container-foreground'
: 'bg-surface-container hover:bg-surface-container-high'
)}>
{!isDemo && (
<span
{...attributes}
{...listeners}
aria-label="Versleep om te sorteren of naar Product Backlog"
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm mt-0.5"
onClick={e => e.stopPropagation()}
>
</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<div className="flex items-center justify-between gap-2 mt-1.5">
<div className="flex items-center gap-1.5 min-w-0">
<Badge className={cn('text-[10px] px-1.5 py-0 border shrink-0', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
<span className="text-xs text-muted-foreground shrink-0">{story.doneCount}/{story.taskCount} klaar</span>
{story.assignee_id ? (
<div className="flex items-center gap-1 min-w-0">
<UserAvatar userId={story.assignee_id} username={story.assignee_username ?? '?'} size="xs" />
<span className="text-xs text-muted-foreground truncate">{story.assignee_username}</span>
</div>
) : (
<span className="text-xs text-muted-foreground italic">Niet geclaimd</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0" onClick={e => e.stopPropagation()}>
<DemoTooltip show={isDemo}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={isDemo}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded bg-transparent border-0 cursor-pointer"
aria-label="Story opties"
>
<MoreHorizontal size={14} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={e => e.stopPropagation()}>
{story.assignee_id !== currentUserId && (
<DropdownMenuItem onClick={handleClaim}>Pak op</DropdownMenuItem>
)}
{story.assignee_id && (
<DropdownMenuItem onClick={handleUnclaim}>Geef terug aan team</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>Wijs toe aan</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{members.map(m => (
<DropdownMenuItem key={m.userId} onClick={e => handleReassign(e, m.userId, m.username)}>
<UserAvatar userId={m.userId} username={m.username} size="xs" />
<span>{m.username}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button
onClick={e => { e.stopPropagation(); if (!isDemo) onEdit() }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk story"
disabled={isDemo}
>
<Pencil size={14} />
</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button
onClick={e => { e.stopPropagation(); if (!isDemo) onRemove() }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Verwijder uit sprint"
disabled={isDemo}
>
<Trash2 size={14} />
</button>
</DemoTooltip>
</div>
</div>
</div>
</div>
</div>
)
}
interface SprintBacklogLeftProps {
sprintId: string
isDemo: boolean
onRemove: (storyId: string) => void
onSelect: (storyId: string) => void
selectedStoryId: string | null
currentUserId: string
productId: string
members: ProductMember[]
onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void
}
export function SprintBacklogLeft({
sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId,
currentUserId, productId, members, onAssigneeChange,
}: SprintBacklogLeftProps) {
const orderedStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
)
const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' })
const [isPending, startTransition] = useTransition()
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length
const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null
function handleClaimAll() {
const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null)
unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername))
startTransition(async () => {
const result = await claimAllUnassignedInActiveSprintAction(productId)
if (!result.success) {
unassigned.forEach(s => onAssigneeChange(s.id, null, null))
toast.error(result.error ?? 'Claimen mislukt')
} else {
toast.success(`${result.count} ${result.count === 1 ? 'story' : 'stories'} geclaimd`)
}
})
}
return (
<div className="flex flex-col h-full" {...debugProps('sprint-backlog-left', 'SprintBacklogLeft', 'components/sprint/sprint-backlog.tsx')}>
<PanelNavBar
title="Sprint Backlog"
actions={
<DemoTooltip show={isDemo}>
<button
onClick={handleClaimAll}
disabled={isDemo || unassignedCount === 0 || isPending}
className="text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
{isPending ? 'Claimen…' : `Claim ongeclaimd (${unassignedCount})`}
</button>
</DemoTooltip>
}
/>
<div
ref={setNodeRef}
data-debug-id="sprint-backlog-left__list"
className={cn(
'flex-1 overflow-y-auto transition-colors',
isOver && 'bg-primary/5 ring-2 ring-inset ring-primary/20 rounded'
)}
>
{orderedStories.length === 0 ? (
<p className={cn(
'text-sm text-muted-foreground text-center mt-8 px-4',
isOver && 'text-primary'
)}>
{isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'}
</p>
) : (
<>
{orderedStories.map(story => (
<SprintRow
key={story.id}
story={story}
isDemo={isDemo}
onRemove={() => onRemove(story.id)}
onSelect={() => onSelect(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
isSelected={selectedStoryId === story.id}
currentUserId={currentUserId}
productId={productId}
members={members}
onAssigneeChange={onAssigneeChange}
/>
))}
</>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}
// --- Right panel: Product Backlog grouped by PBI ---
type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all'
const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_SPRINT', label: 'In Sprint' },
{ value: 'DONE', label: 'Klaar' },
]
type PbiSort = 'code' | 'priority' | 'status'
const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [
{ value: 'code', label: 'Code' },
{ value: 'priority', label: 'Prioriteit' },
{ value: 'status', label: 'Status' },
]
const PBI_STATUS_ORDER: Record<PbiStatusApi, number> = {
ready: 0,
blocked: 1,
failed: 2,
done: 3,
}
function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number {
const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true })
if (sort === 'priority') {
if (a.priority !== b.priority) return a.priority - b.priority
return codeCmp
}
if (sort === 'status') {
const sa = PBI_STATUS_ORDER[a.status] ?? 99
const sb = PBI_STATUS_ORDER[b.status] ?? 99
if (sa !== sb) return sa - sb
return codeCmp
}
return codeCmp
}
function DraggablePbiStoryRow({
story,
isDemo,
onAdd,
}: {
story: SprintStory
isDemo: boolean
onAdd: () => void
}) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `pb:${story.id}` })
const style = transform
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 50, position: 'relative' as const }
: undefined
return (
<div
ref={setNodeRef}
style={style}
className={cn(
'px-2 py-1',
isDemo && 'opacity-60',
isDragging && 'opacity-40'
)}
>
<div className={cn(
'flex items-center gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container',
PRIORITY_BORDER[story.priority],
)}>
{!isDemo && (
<span
{...attributes}
{...listeners}
aria-label="Sleep naar Sprint Backlog"
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm"
>
</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<div className="mt-1.5">
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
</div>
</div>
<DemoTooltip show={isDemo}>
<button
onClick={() => !isDemo && onAdd()}
className="text-xs text-primary hover:underline shrink-0 disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
disabled={isDemo}
>
+ Toevoegen
</button>
</DemoTooltip>
</div>
</div>
)
}
interface SprintBacklogRightProps {
pbisWithStories: PbiWithStories[]
sprintStoryIds: Set<string>
isDemo: boolean
productId: string
onAdd: (storyId: string) => void
}
export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) {
const prefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.sprintBacklog ?? {}),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const filterPriority = prefs.filterPriority ?? 'all'
const filterStatus: StoryStatusFilter = prefs.filterStatus ?? 'OPEN'
const sort: PbiSort = prefs.sort ?? 'code'
const sortDir: SortDir = prefs.sortDir ?? 'asc'
const filterPopoverOpen = prefs.filterPopoverOpen ?? false
const collapsed = useMemo<Set<string>>(() => {
if (prefs.collapsedPbis !== undefined) return new Set(prefs.collapsedPbis)
// Default: auto-collapse PBIs whose stories are all DONE.
const auto = new Set<string>()
for (const pbi of pbisWithStories) {
if (pbi.stories.length > 0 && pbi.stories.every((s) => s.status === 'DONE')) {
auto.add(pbi.id)
}
}
return auto
}, [prefs.collapsedPbis, pbisWithStories])
const setFilterPriority = (v: number | 'all') =>
void setPref(['views', 'sprintBacklog', 'filterPriority'], v)
const setFilterStatus = (v: StoryStatusFilter) =>
void setPref(['views', 'sprintBacklog', 'filterStatus'], v)
const setSort = (v: PbiSort) => void setPref(['views', 'sprintBacklog', 'sort'], v)
const setSortDir = (v: SortDir) => void setPref(['views', 'sprintBacklog', 'sortDir'], v)
const setFilterPopoverOpen = (v: boolean) =>
void setPref(['views', 'sprintBacklog', 'filterPopoverOpen'], v)
const setCollapsedArray = (next: Set<string>) =>
void setPref(['views', 'sprintBacklog', 'collapsedPbis'], Array.from(next))
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
const filteredPbis = pbisWithStories
.map(pbi => ({
...pbi,
stories: pbi.stories.filter(s =>
(filterPriority === 'all' || s.priority === filterPriority) &&
(filterStatus === 'all' || s.status === filterStatus)
),
}))
.filter(pbi => pbi.stories.length > 0)
.sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort))
const activeFilterCount =
(filterPriority !== 'all' ? 1 : 0) +
(filterStatus !== 'OPEN' ? 1 : 0)
function toggle(pbiId: string) {
const next = new Set(collapsed)
if (next.has(pbiId)) next.delete(pbiId)
else next.add(pbiId)
setCollapsedArray(next)
}
function collapseAll() {
setCollapsedArray(new Set(filteredPbis.map((p) => p.id)))
}
function expandAll() {
setCollapsedArray(new Set())
}
const headerActions = (
<>
{filterPriority !== 'all' && (
<button
onClick={() => setFilterPriority('all')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
aria-label="Wis prioriteitsfilter"
>
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
{SHARED_PRIORITY_LABELS[filterPriority]}
</Badge>
<span>×</span>
</button>
)}
{filterStatus !== 'OPEN' && (
<button
onClick={() => setFilterStatus('OPEN')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
aria-label="Wis statusfilter"
>
<Badge className={cn('text-[10px] px-1.5 py-0 border', filterStatus === 'all' ? '' : STATUS_COLORS[filterStatus])}>
{filterStatus === 'all' ? 'Alle' : STATUS_LABELS[filterStatus]}
</Badge>
<span>×</span>
</button>
)}
<BacklogFilterPopover
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
filterPriority={filterPriority}
onFilterPriorityChange={setFilterPriority}
filterStatus={filterStatus}
onFilterStatusChange={setFilterStatus}
statusOptions={STATUS_OPTIONS_SPRINT}
sort={sort}
onSortChange={setSort}
sortDir={sortDir}
onSortDirChange={setSortDir}
sortOptions={SORT_OPTIONS_SPRINT}
activeFilterCount={activeFilterCount}
resetDisabled={filterPriority === 'all' && filterStatus === 'OPEN' && sort === 'code' && sortDir === 'asc'}
onReset={() => {
setFilterPriority('all')
setFilterStatus('OPEN')
setSort('code')
setSortDir('asc')
}}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen">
<ChevronsUp size={14} />
</TooltipTrigger>
<TooltipContent>Alles inklappen</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger onClick={expandAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles uitklappen">
<ChevronsDown size={14} />
</TooltipTrigger>
<TooltipContent>Alles uitklappen</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
)
return (
<div className="flex flex-col h-full" {...debugProps('sprint-backlog-right', 'SprintBacklogRight', 'components/sprint/sprint-backlog.tsx')}>
<PanelNavBar title="Product Backlog" actions={headerActions} />
<div
ref={setNodeRef}
data-debug-id="sprint-backlog-right__list"
className={cn(
'flex-1 overflow-y-auto py-2 transition-colors',
isOver && 'bg-error/5 ring-2 ring-inset ring-error/20 rounded'
)}
>
{filteredPbis.map(pbi => (
<div key={pbi.id}>
<div
role="button"
tabIndex={0}
onClick={() => toggle(pbi.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(pbi.id) } }}
className="group w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left select-none cursor-pointer"
>
<span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span>
<span className="text-sm font-medium truncate flex-1">{pbi.title}</span>
{pbi.code && <CodeBadge code={pbi.code} />}
<span className="text-xs text-muted-foreground">
{pbi.stories.filter(s => s.status === 'DONE').length}/{pbi.stories.length} klaar
</span>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => {
e.stopPropagation()
if (!isDemo) setPbiDialogState({ mode: 'edit', productId, pbi: { id: pbi.id, title: pbi.title, code: pbi.code, priority: pbi.priority, status: pbi.status, description: pbi.description } })
}}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk PBI"
disabled={isDemo}
>
<Pencil size={14} />
</button>
</DemoTooltip>
</div>
{!collapsed.has(pbi.id) && pbi.stories.map(story => {
const inSprint = sprintStoryIds.has(story.id)
if (inSprint) {
return (
<div key={story.id} className="px-2 py-1 opacity-40">
<div className={cn(
'flex items-center gap-2 rounded border border-border px-3 py-2 bg-surface-container',
PRIORITY_BORDER[story.priority]
)}>
<div className="w-[14px] shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm leading-snug line-clamp-2 flex-1">{story.title}</p>
{story.code && <CodeBadge code={story.code} className="shrink-0 mt-0.5" />}
</div>
<div className="mt-1.5">
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">In Sprint</span>
</div>
</div>
)
}
return <DraggablePbiStoryRow key={story.id} story={story} isDemo={isDemo} onAdd={() => onAdd(story.id)} />
})}
</div>
))}
</div>
<PbiDialog
state={pbiDialogState}
onClose={() => setPbiDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}