'use client' import { useState, useTransition } from 'react' import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' import { SortableContext, useSortable, rectSortingStrategy, arrayMove, sortableKeyboardCoordinates, } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { useShallow } from 'zustand/react/shallow' import { useUserSettingsStore } from '@/stores/user-settings/store' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors' import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' import { debugProps } from '@/lib/debug' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { cn } from '@/lib/utils' type SortMode = 'priority' | 'code' | 'date' const STATUS_COLORS: Record = { 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 = { OPEN: 'Open', IN_SPRINT: 'In Sprint', DONE: 'Klaar', } export interface Story { id: string code: string | null title: string description: string | null acceptance_criteria: string | null priority: number sort_order: number status: string pbi_id: string sprint_id: string | null created_at: Date } interface StoryPanelProps { productId: string isDemo: boolean } // --- Sortable story block --- function SortableStoryBlock({ story, isSelected, onSelect, onEdit, }: { story: Story isSelected: boolean onSelect: () => void onEdit: () => 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, } return ( {STATUS_LABELS[story.status] ?? story.status} } actions={ } /> ) } // --- Main component --- // PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi // (useShallow). DnD via applyOptimisticMutation('story-order'). export function StoryPanel({ productId, isDemo }: StoryPanelProps) { const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[] const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) const sortMode: SortMode = useUserSettingsStore( (s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority', ) const setPref = useUserSettingsStore((s) => s.setPref) const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v) const [storyDialogState, setStoryDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) const orderedStories = rawStories const base = orderedStories .filter(s => !filterStatus || s.status === filterStatus) .filter(s => !filterPriority || s.priority === filterPriority) 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() } 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 || !selectedPbiId) return const activeStory = storyMap[active.id as string] const overStory = storyMap[over.id as string] if (!activeStory || !overStory) return const store = useProductWorkspaceStore.getState() const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])] 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) const orderMutationId = store.applyOptimisticMutation({ kind: 'story-order', pbiId: selectedPbiId, prevStoryIds: prevOrder, }) useProductWorkspaceStore.setState((s) => { s.relations.storyIdsByPbi[selectedPbiId] = newOrder }) const priorityChanged = activeStory.priority !== overStory.priority let priorityMutationId: string | null = null if (priorityChanged) { priorityMutationId = store.applyOptimisticMutation({ kind: 'entity-patch', entity: 'story', id: active.id as string, prev: store.entities.storiesById[active.id as string], }) useProductWorkspaceStore.setState((s) => { const story = s.entities.storiesById[active.id as string] if (story) story.priority = overStory.priority }) } startTransition(async () => { const result = await reorderStoriesAction( selectedPbiId, productId, newOrder, priorityChanged ? overStory.priority : undefined ) const st = useProductWorkspaceStore.getState() if (result.success) { if (priorityMutationId) st.settleMutation(priorityMutationId) st.settleMutation(orderMutationId) } else { if (priorityMutationId) st.rollbackMutation(priorityMutationId) st.rollbackMutation(orderMutationId) toast.error('Volgorde opslaan mislukt') } }) } const hasActiveFilters = filterStatus !== null || filterPriority !== null return (
{hasActiveFilters && ( )} {selectedPbiId && ( )} } />
{selectedPbiId === null ? ( ) : rawStories.length === 0 ? ( setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} /> ) : ( s.id)} strategy={rectSortingStrategy}>
{filtered.map(story => ( useProductWorkspaceStore.getState().setActiveStory(story.id)} onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} /> ))}
{activeDragId && storyMap[activeDragId] && ( )}
)}
setStoryDialogState(null)} isDemo={isDemo} />
) }