'use client' import { useState, useTransition, useEffect } from 'react' import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react' import { useDroppable, useDraggable } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' import { useShallow } from 'zustand/react/shallow' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { readLocalStoragePref } from '@/lib/use-local-storage-pref' import { BacklogFilterPopover, PRIORITY_LABELS as SHARED_PRIORITY_LABELS, type SortDir, } from '@/components/shared/backlog-filter-popover' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from '@/components/ui/dropdown-menu' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { UserAvatar } from '@/components/shared/user-avatar' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog' import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog' import type { PbiStatusApi } from '@/lib/task-status' import { cn } from '@/lib/utils' import { debugProps } from '@/lib/debug' 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 SprintStory { id: string code: string | null title: string description: string | null acceptance_criteria: string | null pbi_id: string sprint_id: string | null created_at: Date priority: number sort_order: number status: string taskCount: number doneCount: number assignee_id: string | null assignee_username: string | null } export interface ProductMember { userId: string username: string } export interface PbiWithStories { id: string code: string | null title: string priority: number status: PbiStatusApi description: string | null stories: SprintStory[] } // --- Left panel: Sprint Backlog --- function SortableSprintRow({ story, isDemo, onRemove, onSelect, onEdit, isSelected, currentUserId, productId, members, onAssigneeChange, }: { story: SprintStory isDemo: boolean onRemove: () => void onSelect: () => void onEdit: () => void isSelected: boolean currentUserId: string productId: string members: ProductMember[] onAssigneeChange: (storyId: string, id: string | null, username: string | null) => 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 } const [, startTransition] = useTransition() function handleClaim(e: React.MouseEvent) { e.stopPropagation() const me = members.find(m => m.userId === currentUserId) onAssigneeChange(story.id, currentUserId, me?.username ?? null) startTransition(async () => { const result = await claimStoryAction(story.id, productId) if (!result.success) { onAssigneeChange(story.id, story.assignee_id, story.assignee_username) toast.error(result.error ?? 'Claimen mislukt') } else { toast.success('Story geclaimd') } }) } function handleUnclaim(e: React.MouseEvent) { e.stopPropagation() onAssigneeChange(story.id, null, null) startTransition(async () => { const result = await unclaimStoryAction(story.id, productId) if (!result.success) { onAssigneeChange(story.id, story.assignee_id, story.assignee_username) toast.error(result.error ?? 'Teruggeven mislukt') } }) } function handleReassign(e: React.MouseEvent, targetUserId: string, targetUsername: string) { e.stopPropagation() onAssigneeChange(story.id, targetUserId, targetUsername) startTransition(async () => { const result = await reassignStoryAction(story.id, productId, targetUserId) if (!result.success) { onAssigneeChange(story.id, story.assignee_id, story.assignee_username) toast.error(result.error ?? 'Toewijzen mislukt') } else { toast.success(`Toegewezen aan ${targetUsername}`) } }) } return (
{!isDemo && ( e.stopPropagation()} > ⠿ )}

{story.title}

{story.code && }
{STATUS_LABELS[story.status]} {story.doneCount}/{story.taskCount} klaar {story.assignee_id ? (
{story.assignee_username}
) : ( Niet geclaimd )}
e.stopPropagation()}> e.stopPropagation()}> {story.assignee_id !== currentUserId && ( Pak op )} {story.assignee_id && ( Geef terug aan team )} Wijs toe aan {members.map(m => ( handleReassign(e, m.userId, m.username)}> {m.username} ))}
) } interface SprintBacklogLeftProps { sprintId: string isDemo: boolean onRemove: (storyId: string) => void onSelect: (storyId: string) => void selectedStoryId: string | null currentUserId: string productId: string members: ProductMember[] onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void } export function SprintBacklogLeft({ sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId, currentUserId, productId, members, onAssigneeChange, }: SprintBacklogLeftProps) { const orderedStories = useSprintWorkspaceStore( useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), ) const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' }) const [isPending, startTransition] = useTransition() const [storyDialogState, setStoryDialogState] = useState(null) const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null function handleClaimAll() { const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null) unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername)) startTransition(async () => { const result = await claimAllUnassignedInActiveSprintAction(productId) if (!result.success) { unassigned.forEach(s => onAssigneeChange(s.id, null, null)) toast.error(result.error ?? 'Claimen mislukt') } else { toast.success(`${result.count} ${result.count === 1 ? 'story' : 'stories'} geclaimd`) } }) } return (
} />
{orderedStories.length === 0 ? (

{isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'}

) : ( s.id)} strategy={verticalListSortingStrategy}> {orderedStories.map(story => ( onRemove(story.id)} onSelect={() => onSelect(story.id)} onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} isSelected={selectedStoryId === story.id} currentUserId={currentUserId} productId={productId} members={members} onAssigneeChange={onAssigneeChange} /> ))} )}
setStoryDialogState(null)} isDemo={isDemo} />
) } // --- Right panel: Product Backlog grouped by PBI --- type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all' const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [ { value: 'all', label: 'Alle' }, { value: 'OPEN', label: 'Open' }, { value: 'IN_SPRINT', label: 'In Sprint' }, { value: 'DONE', label: 'Klaar' }, ] type PbiSort = 'code' | 'priority' | 'status' const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [ { value: 'code', label: 'Code' }, { value: 'priority', label: 'Prioriteit' }, { value: 'status', label: 'Status' }, ] const PBI_STATUS_ORDER: Record = { ready: 0, blocked: 1, failed: 2, done: 3, } function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number { const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true }) if (sort === 'priority') { if (a.priority !== b.priority) return a.priority - b.priority return codeCmp } if (sort === 'status') { const sa = PBI_STATUS_ORDER[a.status] ?? 99 const sb = PBI_STATUS_ORDER[b.status] ?? 99 if (sa !== sb) return sa - sb return codeCmp } return codeCmp } function DraggablePbiStoryRow({ story, isDemo, onAdd, }: { story: SprintStory isDemo: boolean onAdd: () => void }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `pb:${story.id}` }) const style = transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 50, position: 'relative' as const } : undefined return (
{!isDemo && ( )}

{story.title}

{story.code && }
{STATUS_LABELS[story.status]}
) } interface SprintBacklogRightProps { pbisWithStories: PbiWithStories[] sprintStoryIds: Set isDemo: boolean productId: string onAdd: (storyId: string) => void } export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) { const [collapsed, setCollapsed] = useState>(() => { const auto = new Set() for (const pbi of pbisWithStories) { if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { auto.add(pbi.id) } } return auto }) const [filterPriority, setFilterPriority] = useState('all') const [filterStatus, setFilterStatus] = useState('OPEN') const [sort, setSort] = useState('code') const [sortDir, setSortDir] = useState('asc') const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) const [prefsLoaded, setPrefsLoaded] = useState(false) const [pbiDialogState, setPbiDialogState] = useState(null) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) // Hydrate prefs from localStorage post-mount. SSR & first client render use // defaults — matched HTML so no hydration error. After mount we apply saved // values; users with saved == default see no visible change, others see one // filter update during hydration. useEffect(() => { /* eslint-disable react-hooks/set-state-in-effect */ setFilterPriority(readLocalStoragePref( 'scrum4me:sprint_pb_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:sprint_pb_filter_status', (raw) => (raw === 'OPEN' || raw === 'IN_SPRINT' || raw === 'DONE' || raw === 'all') ? raw : null, 'OPEN', )) setSort(readLocalStoragePref( 'scrum4me:sprint_pb_sort', (raw) => (raw === 'priority' || raw === 'status' || raw === 'code') ? raw : null, 'code', )) setSortDir(readLocalStoragePref( 'scrum4me:sprint_pb_sort_dir', (raw) => (raw === 'asc' || raw === 'desc') ? raw : null, 'asc', )) const savedCollapsed = localStorage.getItem('scrum4me:sprint_pb_collapsed') if (savedCollapsed) { try { const arr = JSON.parse(savedCollapsed) if (Array.isArray(arr)) { setCollapsed(new Set(arr.filter((x): x is string => typeof x === 'string'))) } } catch { /* ignore malformed JSON */ } } setFilterPopoverOpen(localStorage.getItem('scrum4me:sprint_pb_filter_popover_open') === 'true') setPrefsLoaded(true) /* eslint-enable react-hooks/set-state-in-effect */ }, []) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort', sort) }, [sort, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_sort_dir', sortDir) }, [sortDir, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(Array.from(collapsed))) }, [collapsed, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', String(filterPopoverOpen)) }, [filterPopoverOpen, prefsLoaded]) const filteredPbis = pbisWithStories .map(pbi => ({ ...pbi, stories: pbi.stories.filter(s => (filterPriority === 'all' || s.priority === filterPriority) && (filterStatus === 'all' || s.status === filterStatus) ), })) .filter(pbi => pbi.stories.length > 0) .sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort)) const activeFilterCount = (filterPriority !== 'all' ? 1 : 0) + (filterStatus !== 'OPEN' ? 1 : 0) function toggle(pbiId: string) { setCollapsed(prev => { const next = new Set(prev) if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) } return next }) } function collapseAll() { setCollapsed(new Set(filteredPbis.map(p => p.id))) } function expandAll() { setCollapsed(new Set()) } const headerActions = ( <> {filterPriority !== 'all' && ( )} {filterStatus !== 'OPEN' && ( )} { setFilterPriority('all') setFilterStatus('OPEN') setSort('code') setSortDir('asc') }} /> Alles inklappen Alles uitklappen ) return (
{filteredPbis.map(pbi => (
toggle(pbi.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(pbi.id) } }} className="group w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left select-none cursor-pointer" > {collapsed.has(pbi.id) ? '▶' : '▼'} {pbi.title} {pbi.code && } {pbi.stories.filter(s => s.status === 'DONE').length}/{pbi.stories.length} klaar
{!collapsed.has(pbi.id) && pbi.stories.map(story => { const inSprint = sprintStoryIds.has(story.id) if (inSprint) { return (

{story.title}

{story.code && }
{STATUS_LABELS[story.status]}
In Sprint
) } return onAdd(story.id)} /> })}
))}
setPbiDialogState(null)} isDemo={isDemo} />
) }