diff --git a/__tests__/actions/story-claim.test.ts b/__tests__/actions/story-claim.test.ts index 94b6e57..6fba5e5 100644 --- a/__tests__/actions/story-claim.test.ts +++ b/__tests__/actions/story-claim.test.ts @@ -37,7 +37,7 @@ import { claimAllUnassignedInActiveSprintAction, } from '@/actions/stories' -const mockPrisma = prisma as { +const mockPrisma = prisma as unknown as { story: { findFirst: ReturnType update: ReturnType diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 620f0d3..bff7432 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -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 = {} @@ -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} /> diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index 143e90d..fbab7ea 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -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 = { @@ -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 (
e.stopPropagation()} > ⠿ @@ -85,16 +147,58 @@ function SortableSprintRow({ {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 && ( + + )}
- {!isDemo && ( - - )} ) } @@ -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} /> ))} diff --git a/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index 3c064d2..9bb98ab 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -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 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(stories) const [sprintStoryIds, setSprintStoryIds] = useState>(() => 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={