'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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' import { useBacklogStore } from '@/stores/backlog-store' 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' const PRIORITY_LABELS: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag', } 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' }, ] const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [ { value: 'all', label: 'Alle' }, { value: 1, label: 'Kritiek' }, { value: 2, label: 'Hoog' }, { value: 3, label: 'Gemiddeld' }, { value: 4, label: 'Laag' }, ] const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [ { value: 'all', label: 'Alle' }, { value: 'ready', label: 'Klaar' }, { value: 'blocked', label: 'Geblokkeerd' }, { value: 'done', label: 'Afgerond' }, ] function FilterPills({ label, options, value, onChange, }: { label: string options: Array<{ value: T; label: string }> value: T onChange: (v: T) => void }) { return (

{label}

{options.map((opt) => ( ))}
) } 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 --- export function PbiList({ productId, isDemo }: PbiListProps) { const pbis = useBacklogStore((s) => s.pbis) const { selectedPbiId, selectPbi } = useSelectionStore() const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() // Defaults match SSR; persisted values applied post-mount in the loader effect below. // This avoids hydration mismatch when localStorage holds non-default values. const [filterPriority, setFilterPriority] = useState('all') const [filterStatus, setFilterStatus] = useState('all') const [sortMode, setSortMode] = useState('priority') const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc') 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 }) } // Load persisted preferences once after mount (client-only). // setState calls here are intentional: hydrating from localStorage on first paint. useEffect(() => { const savedSort = localStorage.getItem('scrum4me:pbi_sort') if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') { // eslint-disable-next-line react-hooks/set-state-in-effect setSortMode(savedSort) } const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority') if (savedPriority && savedPriority !== 'all') { const n = parseInt(savedPriority, 10) if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) } const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status') if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') { setFilterStatus(savedStatus) } const savedDir = localStorage.getItem('scrum4me:pbi_sort_dir') if (savedDir === 'asc' || savedDir === 'desc') setSortDir(savedDir) setPrefsLoaded(true) }, []) // Persist on change, but skip the initial render so we don't overwrite saved values with defaults. 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]) // Sync server data into store — use stable string dep to avoid infinite loop const pbiIdKey = pbis.map(p => p.id).join(',') useEffect(() => { initPbis(productId, pbiIdKey ? pbiIdKey.split(',') : []) // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, pbiIdKey]) // Build ordered PBI list from store (or fall back to server order) const order = pbiOrder[productId] ?? pbis.map(p => p.id) const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) // Apply priority overrides from store const orderedPbis = order .map(id => pbiMap[id]) .filter(Boolean) .map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority })) 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) => { if (sortMode === 'code') { return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) } if (sortMode === 'date') { return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() } // priority: sort by priority asc, then drag-and-drop sort_order within group return a.priority !== b.priority ? a.priority - b.priority : 0 }) 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 prevOrder = [...order] const oldIndex = order.indexOf(active.id as string) const newIndex = order.indexOf(over.id as string) const newOrder = arrayMove([...order], oldIndex, newIndex) // Optimistic update reorderPbis(productId, newOrder) const priorityChanged = activePbi.priority !== overPbi.priority startTransition(async () => { if (priorityChanged) { updatePbiPriority(active.id as string, overPbi.priority) const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId) if (!result.success) { rollbackPbis(productId, prevOrder) toast.error('Prioriteit opslaan mislukt') } } else { const result = await reorderPbisAction(productId, newOrder) if (!result.success) { rollbackPbis(productId, prevOrder) toast.error('Volgorde opslaan mislukt') } } }) } function handleDelete(id: string) { startTransition(async () => { await deletePbiAction(id) if (selectedPbiId === id) selectPbi(null) }) } const activePbi = activeDragId ? pbiMap[activeDragId] : null return (
{filterPriority !== 'all' && ( )} {filterStatus !== 'all' && ( )} {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} } />

Sorteren op

{SORT_OPTIONS.map((opt) => ( ))}
{pbis.length === 0 ? ( setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }} /> ) : ( p.id)} strategy={verticalListSortingStrategy} >
{filtered.map(pbi => ( selectPbi(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() }} />
) }