Scrum4Me/components/sprint/sprint-backlog.tsx
Janpeter Visser d292e445d9
Sprint: Verbeteren debug mode (#179)
* feat(PBI-49): add debugProps helper + Vitest test

Adds lib/debug.ts with debugProps(id, component, file) that returns
data-debug-id and data-debug-label attrs in dev mode, empty object in
production. Adds __tests__/lib/debug.test.ts covering both modes.

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

* docs(PBI-49): add debug-id pattern doc + CLAUDE.md reference

Adds docs/patterns/debug-id.md documenting the named-component boundary
rule (6 punten), helper-voorbeeld, skip-criteria en motivatie voor
handmatige pad-argumenten. Voegt verwijzing toe aan CLAUDE.md
patterns-tabel.

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

* refactor(PBI-49): migrate 17 shared/ components to debugProps helper

Replace hardcoded data-debug-id + data-debug-label attribute pairs with
{...debugProps(id, component, file)} spread in all 17 components/shared/
files. Existing debug-ids preserved unchanged.

* feat(PBI-49): add debugProps to backlog/, sprint/, solo/ components

* feat(PBI-49): add debugProps to jobs/ + ideas/ components

* feat(PBI-49): add debugProps to products/ + settings/ + notifications/ components

* feat(PBI-49): add debugProps to admin/ + dashboard/ + dialogs/ + mobile/ + split-pane/

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

* fix(PBI-49): use attr(data-debug-id) for debug tooltip in globals.css

* refactor(PBI-49): remove data-debug-label from debugProps helper + test

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

* refactor(PBI-49): strip unused component/file args from debugProps in shared/

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

* feat(PBI-49): add BEM sub-element data-debug-id to StatusBar, NavBar, PanelNavBar

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

* feat(PBI-49): add BEM sub-element data-debug-id to components/sprint/*

- new-sprint-dialog: __submit on submit button
- sprint-backlog: __list on SprintBacklogLeft + SprintBacklogRight scroll areas
- sprint-board-client: root wrapper div (display:contents) + __drag-overlay
- sprint-header: __title on goal button, __dates on dates button, __actions on action cluster
- sprint-run-controls: root on controls div, __start/__cancel on action buttons; __blockers-dialog on dialog content
- start-sprint-button: root on trigger button, __dialog on dialog content, __submit on submit button
- sync-active-sprint-cookie: no debug-id (returns null, side-effect only), comment added

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

* feat(PBI-49): add BEM sub-element data-debug-id to components/backlog/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/ideas/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/dashboard/* + components/markdown.tsx

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

* feat(PBI-49): add BEM sub-element data-debug-id to new-product-button

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

* feat(PBI-49): add BEM sub-element data-debug-id to components/solo/*

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

* feat(PBI-49): add BEM sub-elements to nav-status-indicators

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

* feat(PBI-49): add BEM sub-element data-debug-id to components/jobs/*

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

* feat(PBI-49): add BEM sub-element data-debug-id to components/products/*

* feat(PBI-49): add BEM sub-element data-debug-id to components/notifications/*

- answer-modal: __content (scroll area), __submit (footer)
- notifications-bridge: skip comment (bridge, non-rendering wrapper)
- notifications-realtime-mount: skip comment (returns null)
- notifications-sheet: __header, __items (questions list)
- push-toggle: __switch (button), __label (button text) on subscribed/unsubscribed states

* feat(PBI-49): add BEM sub-element data-debug-id to components/settings/*

- leave-product-button: root only (single-button component)
- min-quota-editor: __input (number input), __save (save button)
- profile-editor: __username (bio/short-description input), __save (submit)
- role-manager: __roles (checkbox list), __add (save button)
- token-manager: __tokens (active tokens list), __generate (create button)

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

* feat(PBI-49): add BEM sub-element data-debug-id to admin, auth, dialogs, entity-dialog, mobile, split-pane

* docs(PBI-49): add debug-labels BEM pattern doc + CLAUDE.md entry

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 22:46:29 +02:00

714 lines
27 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 { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter, Pencil } from 'lucide-react'
import { useDroppable, useDraggable } from '@dnd-kit/core'
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { CodeBadge } from '@/components/shared/code-badge'
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 { useSprintStore } from '@/stores/sprint-store'
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
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 SortableSprintRow({
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, transform, transition, isDragging } = useSortable({ id: story.id })
const style = { transform: CSS.Transform.toString(transform), transition, 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
stories: SprintStory[]
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, stories, isDemo, onRemove, onSelect, selectedStoryId,
currentUserId, productId, members, onAssigneeChange,
}: SprintBacklogLeftProps) {
const { sprintStoryOrder } = useSprintStore()
const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' })
const [isPending, startTransition] = useTransition()
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const unassignedCount = stories.filter(s => s.assignee_id === null).length
const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null
function handleClaimAll() {
const unassigned = stories.filter(s => s.assignee_id === 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`)
}
})
}
const storyMap = Object.fromEntries(stories.map(s => [s.id, s]))
const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
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>
) : (
<SortableContext items={orderedStories.map(s => s.id)} strategy={verticalListSortingStrategy}>
{orderedStories.map(story => (
<SortableSprintRow
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}
/>
))}
</SortableContext>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}
// --- Right panel: Product Backlog grouped by PBI ---
const PRIORITY_LABELS_SPRINT: Record<number, string> = {
1: 'Kritiek',
2: 'Hoog',
3: 'Gemiddeld',
4: 'Laag',
}
const PRIORITY_OPTIONS_SPRINT: Array<{ value: number | 'all'; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 1, label: 'Kritiek' },
{ value: 2, label: 'Hoog' },
{ value: 3, label: 'Gemiddeld' },
{ value: 4, label: 'Laag' },
]
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' },
]
function FilterPills<T extends string | number>({
label,
options,
value,
onChange,
}: {
label: string
options: Array<{ value: T; label: string }>
value: T
onChange: (v: T) => void
}) {
return (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
value === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
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 [collapsed, setCollapsed] = useState<Set<string>>(() => {
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
})
const [filterPriority, setFilterPriority] = useState<number | 'all'>('all')
const [filterStatus, setFilterStatus] = useState<StoryStatusFilter>('all')
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
// Hydrate filter prefs from localStorage post-mount (avoids SSR mismatch).
// setState calls here are intentional: hydrating from localStorage on first paint.
useEffect(() => {
const savedPriority = localStorage.getItem('scrum4me:sprint_pb_filter_priority')
if (savedPriority && savedPriority !== 'all') {
const n = parseInt(savedPriority, 10)
// eslint-disable-next-line react-hooks/set-state-in-effect
if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n)
}
const savedStatus = localStorage.getItem('scrum4me:sprint_pb_filter_status')
if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') {
setFilterStatus(savedStatus)
}
setPrefsLoaded(true)
}, [])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_status', filterStatus) }, [filterStatus, prefsLoaded])
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)
const activeFilterCount =
(filterPriority !== 'all' ? 1 : 0) +
(filterStatus !== 'all' ? 1 : 0)
function toggle(pbiId: string) {
setCollapsed(prev => {
const next = new Set(prev)
if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) }
return next
})
}
function collapseAll() {
setCollapsed(new Set(filteredPbis.map(p => p.id)))
}
function expandAll() {
setCollapsed(new Set())
}
function onlyNotDone() {
const auto = new Set<string>()
for (const pbi of filteredPbis) {
if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) {
auto.add(pbi.id)
}
}
setCollapsed(auto)
}
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])}>
{PRIORITY_LABELS_SPRINT[filterPriority]}
</Badge>
<span>×</span>
</button>
)}
{filterStatus !== 'all' && (
<button
onClick={() => setFilterStatus('all')}
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', STATUS_COLORS[filterStatus])}>
{STATUS_LABELS[filterStatus]}
</Badge>
<span>×</span>
</button>
)}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="h-7 text-xs">
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
</Button>
}
/>
<PopoverContent align="end" className="w-72 space-y-4">
<FilterPills
label="Prioriteit"
options={PRIORITY_OPTIONS_SPRINT}
value={filterPriority}
onChange={setFilterPriority}
/>
<FilterPills
label="Status"
options={STATUS_OPTIONS_SPRINT}
value={filterStatus}
onChange={setFilterStatus}
/>
<div className="flex justify-end pt-1 border-t border-border">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={activeFilterCount === 0}
onClick={() => {
setFilterPriority('all')
setFilterStatus('all')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
<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>
<Tooltip>
<TooltipTrigger onClick={onlyNotDone} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alleen niet klaar">
<ListFilter size={14} />
</TooltipTrigger>
<TooltipContent>Alleen niet klaar</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>
)
}