'use client' import { useState, useTransition } from 'react' import { Trash2, MoreHorizontal } 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 { 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 { useSprintStore } from '@/stores/sprint-store' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' import { cn } from '@/lib/utils' 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' } const PRIORITY_COLORS: Record = { 1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30', 2: 'bg-priority-high/15 text-priority-high border-priority-high/30', 3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30', 4: 'bg-priority-low/15 text-priority-low border-priority-low/30', } const PRIORITY_LABELS: Record = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } export interface SprintStory { id: string code: string | null title: string 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 stories: SprintStory[] } // --- Left panel: Sprint Backlog --- function SortableSprintRow({ story, isDemo, onRemove, onSelect, isSelected, currentUserId, productId, members, onAssigneeChange, }: { story: SprintStory isDemo: boolean onRemove: () => void onSelect: () => 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 && }
{PRIORITY_LABELS[story.priority]} {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} ))} {!isDemo && ( )}
) } 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 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)} isSelected={selectedStoryId === story.id} currentUserId={currentUserId} productId={productId} members={members} onAssigneeChange={onAssigneeChange} /> ))} )}
) } // --- Right panel: Product Backlog grouped by PBI --- 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 && ( e.stopPropagation()} > ⠿ )}

{story.title}

{story.code && }
{STATUS_LABELS[story.status]}
{!isDemo && ( + toevoegen )}
) } interface SprintBacklogRightProps { pbisWithStories: PbiWithStories[] sprintStoryIds: Set isDemo: boolean onAdd: (storyId: string) => void } export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, onAdd }: SprintBacklogRightProps) { const [collapsed, setCollapsed] = useState>(new Set()) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) function toggle(pbiId: string) { setCollapsed(prev => { const next = new Set(prev) if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) } return next }) } return (
{pbisWithStories.map(pbi => (
{!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)} /> })}
))}
) }