Scrum4Me/components/backlog/pbi-list.tsx
Madhura68 8d6fbdfc3d fix(PBI-79): PBI-rij selecteert weer in A′/B-modus; vinkje is aparte trigger
Voor PBI-79 maakte het hele PBI-kaartje in selectionMode (state A′ én B)
de toggle. Daardoor:
- klik op rij = bulk-toggle stories (teller liep op);
- geen setActivePbi, dus StoryPanel kreeg geen content.

Fix: in selectionMode wordt onClick = onSelect (PBI activeren → stories
laden) en de tri-state-iconen verhuizen naar een eigen <button> in de
actions-slot met stopPropagation. Toggle gedrag (bulk add/remove in B,
upsertPbiIntent in A′) blijft ongewijzigd via die knop.

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

583 lines
20 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 } from 'react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, MinusSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
BacklogFilterPopover,
PRIORITY_LABELS,
type SortDir,
} from '@/components/shared/backlog-filter-popover'
import { useShallow } from 'zustand/react/shallow'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import {
selectPbiTriState,
selectVisiblePbis,
type PbiTriState,
} from '@/stores/product-workspace/selectors'
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
import { deletePbiAction } from '@/actions/pbis'
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
import type { PbiStatusApi } from '@/lib/task-status'
type SortMode = 'priority' | 'code' | 'date'
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
{ value: 'priority', label: 'Prioriteit' },
{ value: 'code', label: 'Code' },
{ value: 'date', label: 'Datum' },
]
type PbiStatusFilter = PbiStatusApi | 'all'
const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'ready', label: 'Klaar' },
{ value: 'blocked', label: 'Geblokkeerd' },
{ value: 'done', label: 'Afgerond' },
]
interface Pbi {
id: string
code: string | null
title: string
priority: number
description?: string | null
created_at: Date
status: PbiStatusApi
}
interface PbiListProps {
productId: string
isDemo: boolean
activeSprintId?: string | null
}
// --- Sortable PBI row ---
function TriStateIcon({ state }: { state: PbiTriState }) {
if (state === 'full')
return <CheckSquare size={18} className="text-primary" />
if (state === 'partial')
return <MinusSquare size={18} className="text-primary" />
return <Square size={18} />
}
function SortablePbiRow({
pbi,
isSelected,
isDemo,
selectionMode,
triState,
onSelect,
onToggleCheck,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
selectionMode: boolean
triState: PbiTriState
onSelect: () => void
onToggleCheck: () => void
onEdit: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pbi.id,
disabled: selectionMode,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
if (selectionMode) {
return (
<BacklogCard
ref={setNodeRef}
style={style}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isSelected}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={onSelect}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect()
}
}}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleCheck()
}}
aria-pressed={triState !== 'empty'}
aria-label={
triState === 'full'
? 'Stories uit sprint halen'
: 'Stories aan sprint toevoegen'
}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
>
<TriStateIcon state={triState} />
</button>
}
/>
)
}
return (
<BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isSelected}
isDragging={isDragging}
role="button"
tabIndex={0}
aria-pressed={isSelected}
onClick={onSelect}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<div className="flex items-center gap-1">
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) onEdit() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 border border-border rounded text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk PBI"
disabled={isDemo}
>
</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) onDelete() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-error text-base leading-none disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Verwijder PBI"
disabled={isDemo}
>
×
</button>
</DemoTooltip>
</div>
}
/>
)
}
// --- Main component ---
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) {
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
// voorkomt re-render op ongerelateerde store-mutaties (G2).
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
const prefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.pbiList ?? {}),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const filterPriority = prefs.filterPriority ?? 'all'
const filterStatus: PbiStatusFilter = prefs.filterStatus ?? 'all'
const sortMode: SortMode = prefs.sort ?? 'priority'
const sortDir: SortDir = prefs.sortDir ?? 'asc'
const setFilterPriority = (v: number | 'all') =>
void setPref(['views', 'pbiList', 'filterPriority'], v)
const setFilterStatus = (v: PbiStatusFilter) =>
void setPref(['views', 'pbiList', 'filterStatus'], v)
const setSortMode = (v: SortMode) => void setPref(['views', 'pbiList', 'sort'], v)
const setSortDir = (v: SortDir) => void setPref(['views', 'pbiList', 'sortDir'], v)
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
// PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten:
// A (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent.
// B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer
// via toggleStorySprintMembership per child story (bulk).
// A (geen sprint, geen draft) → geen vinkjes.
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
const toggleStorySprintMembership = useProductWorkspaceStore(
(s) => s.toggleStorySprintMembership,
)
const stateBMode = !hasDraft && !!activeSprintId
const selectionMode = hasDraft || stateBMode
function togglePbiInDraft(id: string, currentState: PbiTriState) {
if (hasDraft) {
// A: empty/partial → all; full → none.
const nextIntent = currentState === 'full' ? 'none' : 'all'
void upsertPbiIntent(productId, id, nextIntent)
return
}
if (stateBMode && activeSprintId) {
// State B: bulk-toggle alle child-stories naar/uit de pending buffer.
const store = useProductWorkspaceStore.getState()
const storyIds = store.relations.storyIdsByPbi[id] ?? []
const goingFull = currentState !== 'full'
for (const storyId of storyIds) {
const story = store.entities.storiesById[storyId]
if (!story) continue
const blocked = store.sprintMembership.crossSprintBlocks[storyId]
if (blocked) continue
const inSprint = story.sprint_id === activeSprintId
if (goingFull && !inSprint) {
toggleStorySprintMembership(storyId, false)
}
if (!goingFull && inSprint) {
toggleStorySprintMembership(storyId, true)
}
}
}
}
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
// Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid.
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
const orderedPbis = pbis
const base = orderedPbis.filter(p => {
if (filterPriority !== 'all' && p.priority !== filterPriority) return false
if (filterStatus !== 'all' && p.status !== filterStatus) return false
return true
})
const activeFilterCount =
(filterPriority !== 'all' ? 1 : 0) +
(filterStatus !== 'all' ? 1 : 0) +
(sortMode !== 'priority' ? 1 : 0) +
(sortDir !== 'asc' ? 1 : 0)
const filtered = [...base].sort((a, b) => {
let cmp = 0
if (sortMode === 'code') {
cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
} else if (sortMode === 'date') {
cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
} else {
cmp = a.priority !== b.priority ? a.priority - b.priority : 0
}
return sortDir === 'desc' ? -cmp : cmp
})
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over || active.id === over.id) return
const activePbi = pbiMap[active.id as string]
const overPbi = pbiMap[over.id as string]
if (!activePbi || !overPbi) return
const store = useProductWorkspaceStore.getState()
const prevOrder = [...store.relations.pbiIds]
const oldIndex = prevOrder.indexOf(active.id as string)
const newIndex = prevOrder.indexOf(over.id as string)
if (oldIndex === -1 || newIndex === -1) return
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
// Snapshot rollback-info en pas optimistisch toe.
const orderMutationId = store.applyOptimisticMutation({
kind: 'pbi-order',
prevPbiIds: prevOrder,
})
useProductWorkspaceStore.setState((s) => {
s.relations.pbiIds = newOrder
})
const priorityChanged = activePbi.priority !== overPbi.priority
let priorityMutationId: string | null = null
if (priorityChanged) {
priorityMutationId = store.applyOptimisticMutation({
kind: 'entity-patch',
entity: 'pbi',
id: active.id as string,
prev: store.entities.pbisById[active.id as string],
})
useProductWorkspaceStore.setState((s) => {
const pbi = s.entities.pbisById[active.id as string]
if (pbi) pbi.priority = overPbi.priority
})
}
startTransition(async () => {
const settle = () => {
const st = useProductWorkspaceStore.getState()
if (priorityMutationId) st.settleMutation(priorityMutationId)
st.settleMutation(orderMutationId)
}
const rollback = (msg: string) => {
const st = useProductWorkspaceStore.getState()
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
st.rollbackMutation(orderMutationId)
toast.error(msg)
}
if (priorityChanged) {
const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId)
if (result.success) settle()
else rollback('Prioriteit opslaan mislukt')
} else {
const result = await reorderPbisAction(productId, newOrder)
if (result.success) settle()
else rollback('Volgorde opslaan mislukt')
}
})
}
function handleDelete(id: string) {
startTransition(async () => {
await deletePbiAction(id)
if (selectedPbiId === id) {
useProductWorkspaceStore.getState().setActivePbi(null)
}
})
}
const activePbi = activeDragId ? pbiMap[activeDragId] : null
return (
<div className="flex flex-col h-full" {...debugProps('pbi-list', 'PbiList', 'components/backlog/pbi-list.tsx')}>
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-container-low shrink-0" {...debugProps('pbi-list__header')}>
{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[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-xs', PBI_STATUS_COLORS[filterStatus])}>
{PBI_STATUS_LABELS[filterStatus]}
</Badge>
<span>×</span>
</button>
)}
<BacklogFilterPopover
open={filterPopoverOpen}
onOpenChange={setFilterPopoverOpen}
filterPriority={filterPriority}
onFilterPriorityChange={setFilterPriority}
filterStatus={filterStatus}
onFilterStatusChange={setFilterStatus}
statusOptions={STATUS_OPTIONS}
sort={sortMode}
onSortChange={setSortMode}
sortDir={sortDir}
onSortDirChange={setSortDir}
sortOptions={SORT_OPTIONS}
activeFilterCount={activeFilterCount}
resetDisabled={activeFilterCount === 0}
onReset={() => {
setFilterPriority('all')
setFilterStatus('all')
setSortMode('priority')
setSortDir('asc')
}}
/>
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo || selectionMode}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
</Button>
</DemoTooltip>
</div>
<div className="flex-1 overflow-y-auto">
{pbis.length === 0 ? (
<EmptyPanel
message="Nog geen PBI's aangemaakt."
action={{ label: 'Maak je eerste PBI aan', onClick: () => setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }}
/>
) : (
<DndContext
id="pbi-list"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filtered.map(p => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
{filtered.map(pbi => (
<SortablePbiRowWithTriState
key={pbi.id}
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
selectionMode={selectionMode}
productId={productId}
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
onToggle={togglePbiInDraft}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activePbi && (
<BacklogCard
title={activePbi.title}
priority={activePbi.priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)}
</div>
<PbiDialog
state={dialogState}
onClose={() => setDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}
// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de
// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij
// re-rendert bij pbiIntent/storyOverrides-mutaties.
function SortablePbiRowWithTriState({
pbi,
isSelected,
isDemo,
selectionMode,
productId,
onSelect,
onToggle,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
selectionMode: boolean
productId: string
onSelect: () => void
onToggle: (id: string, currentState: PbiTriState) => void
onEdit: () => void
onDelete: () => void
}) {
// Tri-state uit pendingSprintDraft (state A) of pbiSummary (state B).
// Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent.
const triState = useUserSettingsStore((s) => {
const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId]
if (draft) {
const intent = draft.pbiIntent[pbi.id] ?? 'none'
const override = draft.storyOverrides[pbi.id]
if (intent === 'all') {
if (override?.remove.length) return 'partial'
return 'full'
}
if (override?.add.length) return 'partial'
return 'empty'
}
return null
})
const summaryTriState = useProductWorkspaceStore((s) =>
selectPbiTriState(s, pbi.id),
)
const effectiveTriState: PbiTriState =
triState ?? (selectionMode ? summaryTriState : 'empty')
return (
<SortablePbiRow
pbi={pbi}
isSelected={isSelected}
isDemo={isDemo}
selectionMode={selectionMode}
triState={effectiveTriState}
onSelect={onSelect}
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
onEdit={onEdit}
onDelete={onDelete}
/>
)
}