Scrum4Me/components/sprint/sprint-backlog.tsx
Madhura68 6fe1a2aaa6 feat: shared backlog filter popover + sprint header polish (v1.3.3)
- Move sprint switcher into sprint header, centered between title and actions
- Extract BacklogFilterPopover as shared component used by sprint and product backlog
- Add sort options (code/priority/status) with single-pill asc/desc toggle
- Default sprint backlog status filter to OPEN, remove "alleen niet klaar" button
- Persist collapsed state and filter popover open in localStorage
- Fix hydration flicker: defer localStorage read to useEffect with prefsLoaded gate for writes
- Increase sprint switcher text size for readability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 11:11:02 +02:00

700 lines
28 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, 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 { useShallow } from 'zustand/react/shallow'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { readLocalStoragePref } from '@/lib/use-local-storage-pref'
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 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
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>
) : (
<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 ---
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 [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>('OPEN')
const [sort, setSort] = useState<PbiSort>('code')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null)
const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' })
// Hydrate prefs from localStorage post-mount. SSR & first client render use
// defaults — matched HTML so no hydration error. After mount we apply saved
// values; users with saved == default see no visible change, others see one
// filter update during hydration.
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect */
setFilterPriority(readLocalStoragePref<number | 'all'>(
'scrum4me:sprint_pb_filter_priority',
(raw) => {
if (raw === 'all') return 'all'
const n = parseInt(raw, 10)
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null
},
'all',
))
setFilterStatus(readLocalStoragePref<StoryStatusFilter>(
'scrum4me:sprint_pb_filter_status',
(raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null,
'OPEN',
))
setSort(readLocalStoragePref<PbiSort>(
'scrum4me:sprint_pb_sort',
(raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null,
'code',
))
setSortDir(readLocalStoragePref<SortDir>(
'scrum4me:sprint_pb_sort_dir',
(raw) => (raw === 'asc' || raw === 'desc') ? raw : null,
'asc',
))
const savedCollapsed = localStorage.getItem('scrum4me:sprint_pb_collapsed')
if (savedCollapsed) {
try {
const arr = JSON.parse(savedCollapsed)
if (Array.isArray(arr)) {
setCollapsed(new Set(arr.filter((x): x is string => typeof x === 'string')))
}
} catch { /* ignore malformed JSON */ }
}
setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true')
setPrefsLoaded(true)
/* eslint-enable react-hooks/set-state-in-effect */
}, [])
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])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort', sort) }, [sort, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort_dir', sortDir) }, [sortDir, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(Array.from(collapsed))) }, [collapsed, prefsLoaded])
useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', String(filterPopoverOpen)) }, [filterPopoverOpen, 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)
.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) {
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())
}
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>
)
}