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:
parent
87eb4a420b
commit
358c88a9d9
4 changed files with 172 additions and 26 deletions
|
|
@ -37,7 +37,7 @@ import {
|
|||
claimAllUnassignedInActiveSprintAction,
|
||||
} from '@/actions/stories'
|
||||
|
||||
const mockPrisma = prisma as {
|
||||
const mockPrisma = prisma as unknown as {
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
|
|||
import { prisma } from '@/lib/prisma'
|
||||
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import type { SprintStory, PbiWithStories } from '@/components/sprint/sprint-backlog'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
import Link from 'next/link'
|
||||
|
||||
|
|
@ -27,16 +27,27 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
})
|
||||
if (!sprint) redirect(`/products/${id}`)
|
||||
|
||||
// Sprint stories with full task data
|
||||
const sprintStories = await prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
tasks: {
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
// Sprint stories with full task data and assignee
|
||||
const [sprintStories, productMembers] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
prisma.productMember.findMany({
|
||||
where: { product_id: id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// All members who can be assigned: owner + product members
|
||||
const members: ProductMember[] = [
|
||||
{ userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' },
|
||||
...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })),
|
||||
]
|
||||
|
||||
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
|
||||
id: s.id,
|
||||
|
|
@ -45,6 +56,8 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
status: s.status,
|
||||
taskCount: s.tasks.length,
|
||||
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
|
||||
assignee_id: s.assignee_id,
|
||||
assignee_username: s.assignee?.username ?? null,
|
||||
}))
|
||||
|
||||
const tasksByStory: Record<string, Task[]> = {}
|
||||
|
|
@ -83,6 +96,8 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
status: s.status,
|
||||
taskCount: 0,
|
||||
doneCount: 0,
|
||||
assignee_id: null,
|
||||
assignee_username: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
|
|
@ -108,6 +123,8 @@ export default async function SprintBoardPage({ params }: Props) {
|
|||
sprintStoryIdList={sprintStoryIdList}
|
||||
tasksByStory={tasksByStory}
|
||||
isDemo={isDemo}
|
||||
currentUserId={session.userId}
|
||||
members={members}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable'
|
|||
import { toast } from 'sonner'
|
||||
import { TriplePane } from '@/components/split-pane/triple-pane'
|
||||
import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog'
|
||||
import type { SprintStory, PbiWithStories } from './sprint-backlog'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog'
|
||||
import { TaskList } from './task-list'
|
||||
import type { Task } from './task-list'
|
||||
import { useSprintStore } from '@/stores/sprint-store'
|
||||
|
|
@ -27,6 +27,8 @@ interface SprintBoardClientProps {
|
|||
sprintStoryIdList: string[]
|
||||
tasksByStory: Record<string, Task[]>
|
||||
isDemo: boolean
|
||||
currentUserId: string
|
||||
members: ProductMember[]
|
||||
}
|
||||
|
||||
export function SprintBoardClient({
|
||||
|
|
@ -37,6 +39,8 @@ export function SprintBoardClient({
|
|||
sprintStoryIdList,
|
||||
tasksByStory,
|
||||
isDemo,
|
||||
currentUserId,
|
||||
members,
|
||||
}: SprintBoardClientProps) {
|
||||
const [sprintStories, setSprintStories] = useState<SprintStory[]>(stories)
|
||||
const [sprintStoryIds, setSprintStoryIds] = useState<Set<string>>(() => new Set(sprintStoryIdList))
|
||||
|
|
@ -161,6 +165,12 @@ export function SprintBoardClient({
|
|||
})
|
||||
}
|
||||
|
||||
function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) {
|
||||
setSprintStories(prev =>
|
||||
prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s)
|
||||
)
|
||||
}
|
||||
|
||||
function handleRemove(storyId: string) {
|
||||
const storyData = sprintStories.find(s => s.id === storyId)
|
||||
setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n })
|
||||
|
|
@ -208,6 +218,10 @@ export function SprintBoardClient({
|
|||
onRemove={handleRemove}
|
||||
onSelect={setSelectedStoryId}
|
||||
selectedStoryId={selectedStoryId}
|
||||
currentUserId={currentUserId}
|
||||
productId={productId}
|
||||
members={members}
|
||||
onAssigneeChange={handleAssigneeChange}
|
||||
/>
|
||||
}
|
||||
right={
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue