Replaces six useState+useEffect+localStorage flows with selectors from useUserSettingsStore. Defaults are applied at the selector level (filterStatus 'OPEN', sort 'code', etc) so the component matches its previous behaviour. The collapsed Set is derived from the persisted array, falling back to auto-collapse-DONE when no preference exists yet. setPref calls are fire-and-forget — the optimistic flow handles the local state update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
667 lines
26 KiB
TypeScript
667 lines
26 KiB
TypeScript
'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 { 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 { 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 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 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>
|
||
)
|
||
}
|