'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 if (state === 'partial') return return } 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 ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() onSelect() } }} badge={ {PBI_STATUS_LABELS[pbi.status]} } actions={ } /> ) } return ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} badge={ {PBI_STATUS_LABELS[pbi.status]} } actions={
} /> ) } // --- 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(null) const [activeDragId, setActiveDragId] = useState(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 (
{filterPriority !== 'all' && ( )} {filterStatus !== 'all' && ( )} { setFilterPriority('all') setFilterStatus('all') setSortMode('priority') setSortDir('asc') }} />
{pbis.length === 0 ? ( setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }} /> ) : ( p.id)} strategy={verticalListSortingStrategy} >
{filtered.map(pbi => ( useProductWorkspaceStore.getState().setActivePbi(pbi.id)} onToggle={togglePbiInDraft} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onDelete={() => handleDelete(pbi.id)} /> ))}
{activePbi && ( )}
)}
setDialogState(null)} isDemo={isDemo} />
) } // 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 ( onToggle(pbi.id, effectiveTriState)} onEdit={onEdit} onDelete={onDelete} /> ) }