'use client' import { useState, useTransition, useEffect } from 'react' import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter, 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 { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { CodeBadge } from '@/components/shared/code-badge' 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 { useSprintStore } from '@/stores/sprint-store' 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 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 stories: SprintStory[] 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, stories, isDemo, onRemove, onSelect, selectedStoryId, currentUserId, productId, members, onAssigneeChange, }: SprintBacklogLeftProps) { const { sprintStoryOrder } = useSprintStore() const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' }) const [isPending, startTransition] = useTransition() const [storyDialogState, setStoryDialogState] = useState(null) const unassignedCount = stories.filter(s => s.assignee_id === null).length const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null function handleClaimAll() { const unassigned = stories.filter(s => s.assignee_id === 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`) } }) } const storyMap = Object.fromEntries(stories.map(s => [s.id, s])) const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id) const orderedStories = order.map(id => storyMap[id]).filter(Boolean) 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 --- const PRIORITY_LABELS_SPRINT: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag', } const PRIORITY_OPTIONS_SPRINT: 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' }, ] 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' }, ] 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) => ( ))}
) } 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('all') const [prefsLoaded, setPrefsLoaded] = useState(false) const [pbiDialogState, setPbiDialogState] = useState(null) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) // Hydrate filter prefs from localStorage post-mount (avoids SSR mismatch). // setState calls here are intentional: hydrating from localStorage on first paint. useEffect(() => { const savedPriority = localStorage.getItem('scrum4me:sprint_pb_filter_priority') if (savedPriority && savedPriority !== 'all') { const n = parseInt(savedPriority, 10) // eslint-disable-next-line react-hooks/set-state-in-effect if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) } const savedStatus = localStorage.getItem('scrum4me:sprint_pb_filter_status') if (savedStatus === 'OPEN' || savedStatus === 'IN_SPRINT' || savedStatus === 'DONE') { setFilterStatus(savedStatus) } setPrefsLoaded(true) }, []) 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]) 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) const activeFilterCount = (filterPriority !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 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()) } function onlyNotDone() { const auto = new Set() for (const pbi of filteredPbis) { if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { auto.add(pbi.id) } } setCollapsed(auto) } const headerActions = ( <> {filterPriority !== 'all' && ( )} {filterStatus !== 'all' && ( )} {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} } />
Alles inklappen Alles uitklappen Alleen niet klaar ) 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} />
) }