'use client' import { useState, useTransition, useEffect } 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, 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 { readLocalStoragePref } from '@/lib/use-local-storage-pref' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { selectVisiblePbis } 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 { NewSprintDialog } from '@/components/sprint/new-sprint-dialog' 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 } // --- Sortable PBI row --- function SortablePbiRow({ pbi, isSelected, isDemo, selectionMode, isChecked, onSelect, onToggleCheck, onEdit, onDelete, }: { pbi: Pbi isSelected: boolean isDemo: boolean selectionMode: boolean isChecked: boolean 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(); onToggleCheck() } }} 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 }: 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 [filterPriority, setFilterPriority] = useState('all') const [filterStatus, setFilterStatus] = useState('all') const [sortMode, setSortMode] = useState('priority') const [sortDir, setSortDir] = useState('asc') const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) const [prefsLoaded, setPrefsLoaded] = useState(false) const [dialogState, setDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [selectionMode, setSelectionMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [newSprintOpen, setNewSprintOpen] = useState(false) const [, startTransition] = useTransition() function exitSelection() { setSelectionMode(false) setSelectedIds(new Set()) } function toggleCheck(id: string) { setSelectedIds(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } // Hydrate prefs post-mount; SSR + first client render use defaults so no // hydration mismatch. Users with saved == default see no change; others see // one filter update right after hydration. useEffect(() => { /* eslint-disable react-hooks/set-state-in-effect */ setSortMode(readLocalStoragePref( 'scrum4me:pbi_sort', (raw) => (raw === 'priority' || raw === 'code' || raw === 'date') ? raw : null, 'priority', )) setFilterPriority(readLocalStoragePref( 'scrum4me:pbi_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( 'scrum4me:pbi_filter_status', (raw) => (raw === 'ready' || raw === 'blocked' || raw === 'done' || raw === 'all') ? raw : null, 'all', )) setSortDir(readLocalStoragePref( 'scrum4me:pbi_sort_dir', (raw) => (raw === 'asc' || raw === 'desc') ? raw : null, 'asc', )) setPrefsLoaded(true) /* eslint-enable react-hooks/set-state-in-effect */ }, []) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort_dir', sortDir) }, [sortDir, prefsLoaded]) // 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)} onToggleCheck={() => toggleCheck(pbi.id)} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onDelete={() => handleDelete(pbi.id)} /> ))}
{activePbi && ( )}
)}
{selectionMode && (
{selectedIds.size} geselecteerd
)} setDialogState(null)} isDemo={isDemo} /> { setNewSprintOpen(open) if (!open) { // Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan } }} onCreated={() => { setNewSprintOpen(false) exitSelection() }} />
) }