'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, 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 { useSelectionStore } from '@/stores/selection-store' import { usePlannerStore } from '@/stores/planner-store' import { useBacklogStore } from '@/stores/backlog-store' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' 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 status: string pbi_id: string 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 --- export function StoryPanel({ productId, isDemo }: StoryPanelProps) { const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore() const storiesByPbi = useBacklogStore((s) => s.storiesByPbi) const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore() const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) const [sortMode, setSortMode] = useState(() => { const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority' }) const [storyDialogState, setStoryDialogState] = useState(null) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode]) const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : [] // Sync into store — use stable string dep to avoid infinite loop const storyIdKey = rawStories.map(s => s.id).join(',') useEffect(() => { if (selectedPbiId) { initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : []) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedPbiId, storyIdKey]) const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id) const orderedStories = order.map(id => storyMap[id]).filter(Boolean) 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 prevOrder = [...order] const oldIndex = order.indexOf(active.id as string) const newIndex = order.indexOf(over.id as string) const newOrder = arrayMove([...order], oldIndex, newIndex) reorderStories(selectedPbiId, newOrder) const priorityChanged = activeStory.priority !== overStory.priority startTransition(async () => { const result = await reorderStoriesAction( selectedPbiId, productId, newOrder, priorityChanged ? overStory.priority : undefined ) if (!result.success) { rollbackStories(selectedPbiId, prevOrder) 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 => ( selectStory(story.id)} onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} /> ))}
{activeDragId && storyMap[activeDragId] && ( )}
)}
setStoryDialogState(null)} isDemo={isDemo} />
) }