feat(ST-353): add assignee chip and claim/reassign dropdown to sprint story card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 16:24:34 +02:00
parent 87eb4a420b
commit 358c88a9d9
4 changed files with 172 additions and 26 deletions

View file

@ -1,13 +1,21 @@
'use client'
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
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 {
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 } from '@/actions/stories'
import { cn } from '@/lib/utils'
const STATUS_COLORS: Record<string, string> = {
@ -32,6 +40,13 @@ export interface SprintStory {
status: string
taskCount: number
doneCount: number
assignee_id: string | null
assignee_username: string | null
}
export interface ProductMember {
userId: string
username: string
}
export interface PbiWithStories {
@ -44,15 +59,62 @@ export interface PbiWithStories {
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 (
<div
@ -60,7 +122,7 @@ function SortableSprintRow({
style={style}
onClick={onSelect}
className={cn(
'group flex items-center gap-3 px-4 py-2.5 border-b border-border cursor-pointer transition-colors',
'group flex items-start gap-3 px-4 py-2.5 border-b border-border cursor-pointer transition-colors',
isSelected
? 'bg-primary-container text-primary-container-foreground'
: 'hover:bg-surface-container'
@ -71,7 +133,7 @@ function SortableSprintRow({
{...attributes}
{...listeners}
aria-label="Versleep om te sorteren of naar Product Backlog"
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm"
className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none text-sm mt-0.5"
onClick={e => e.stopPropagation()}
>
@ -85,16 +147,58 @@ function SortableSprintRow({
</Badge>
<span className="text-xs text-muted-foreground">{story.doneCount}/{story.taskCount} klaar</span>
</div>
<div className="flex items-center gap-1.5 mt-1">
{story.assignee_id ? (
<>
<UserAvatar userId={story.assignee_id} username={story.assignee_username ?? '?'} size="xs" />
<span className="text-xs text-muted-foreground truncate">{story.assignee_username}</span>
</>
) : (
<span className="text-xs text-muted-foreground italic">Niet geclaimd</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0 mt-0.5" onClick={e => e.stopPropagation()}>
<DemoTooltip show={isDemo}>
<DropdownMenu>
<DropdownMenuTrigger
disabled={isDemo}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded bg-transparent border-0 cursor-pointer"
aria-label="Story opties"
>
<MoreHorizontal size={14} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={e => e.stopPropagation()}>
{story.assignee_id !== currentUserId && (
<DropdownMenuItem onClick={handleClaim}>Pak op</DropdownMenuItem>
)}
{story.assignee_id && (
<DropdownMenuItem onClick={handleUnclaim}>Geef terug aan team</DropdownMenuItem>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>Wijs toe aan</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{members.map(m => (
<DropdownMenuItem key={m.userId} onClick={e => handleReassign(e, m.userId, m.username)}>
<UserAvatar userId={m.userId} username={m.username} size="xs" />
<span>{m.username}</span>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</DemoTooltip>
{!isDemo && (
<button
onClick={e => { e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error"
aria-label="Verwijder uit sprint"
>
<Trash2 size={14} />
</button>
)}
</div>
{!isDemo && (
<button
onClick={e => { e.stopPropagation(); onRemove() }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error shrink-0"
aria-label="Verwijder uit sprint"
>
<Trash2 size={14} />
</button>
)}
</div>
)
}
@ -106,9 +210,16 @@ interface SprintBacklogLeftProps {
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 }: SprintBacklogLeftProps) {
export function SprintBacklogLeft({
sprintId, stories, isDemo, onRemove, onSelect, selectedStoryId,
currentUserId, productId, members, onAssigneeChange,
}: SprintBacklogLeftProps) {
const { sprintStoryOrder } = useSprintStore()
const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' })
@ -143,6 +254,10 @@ export function SprintBacklogLeft({ sprintId, stories, isDemo, onRemove, onSelec
onRemove={() => onRemove(story.id)}
onSelect={() => onSelect(story.id)}
isSelected={selectedStoryId === story.id}
currentUserId={currentUserId}
productId={productId}
members={members}
onAssigneeChange={onAssigneeChange}
/>
))}
</SortableContext>