'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 { CheckSquare, Square } from 'lucide-react' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from '@/components/ui/tooltip' 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, selectStoryIsBlocked, } 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 activeSprintId?: string | null } // --- Sortable story block --- function SortableStoryBlock({ story, isSelected, cherrypick, onSelect, onEdit, }: { story: Story isSelected: boolean cherrypick: { checked: boolean blocked: { sprintName: string } | null onToggle: () => void } | null 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={
{cherrypick && }
} /> ) } function StoryCherrypickButton({ checked, blocked, onToggle, }: { checked: boolean blocked: { sprintName: string } | null onToggle: () => void }) { const icon = checked ? ( ) : ( ) if (blocked) { return ( e.stopPropagation()} className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground" > {icon} Zit in sprint {blocked.sprintName} ) } return ( ) } // --- Main component --- // PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi // (useShallow). DnD via applyOptimisticMutation('story-order'). export function StoryPanel({ productId, isDemo, activeSprintId = null }: 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} />
) } // PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling. // Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of // crossSprintBlocks-mutaties. function StoryBlockWithCherrypick({ story, productId, activeSprintId, isSelected, onSelect, onEdit, }: { story: Story productId: string activeSprintId: string | null isSelected: boolean onSelect: () => void onEdit: () => void }) { const draft = useUserSettingsStore( (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId], ) const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride) const toggleStorySprintMembership = useProductWorkspaceStore( (s) => s.toggleStorySprintMembership, ) const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending) const blocked = useProductWorkspaceStore((s) => selectStoryIsBlocked(s, story.id), ) let cherrypick: { checked: boolean blocked: { sprintName: string } | null onToggle: () => void } | null = null if (draft) { // State A′: muteer draft via per-PBI overrides. const intent = draft.pbiIntent[story.pbi_id] ?? 'none' const override = draft.storyOverrides[story.pbi_id] ?? { add: [], remove: [], } const checked = (intent === 'all' && !override.remove.includes(story.id)) || override.add.includes(story.id) cherrypick = { checked, blocked: blocked ? { sprintName: blocked.sprintName } : null, onToggle: () => { if (intent === 'all') { void upsertStoryOverride( productId, story.pbi_id, story.id, checked ? 'remove' : 'clear', ) } else { void upsertStoryOverride( productId, story.pbi_id, story.id, checked ? 'clear' : 'add', ) } }, } } else if (activeSprintId) { // State B: muteer pending buffer via toggleStorySprintMembership. const inSprintDb = story.sprint_id === activeSprintId const inAdds = pending.adds.includes(story.id) const inRemoves = pending.removes.includes(story.id) const checked = inAdds || (inSprintDb && !inRemoves) cherrypick = { checked, blocked: blocked ? { sprintName: blocked.sprintName } : null, onToggle: () => { toggleStorySprintMembership(story.id, inSprintDb) }, } } return ( ) }