diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts
index 875ab1d..6adb153 100644
--- a/__tests__/actions/sprint-dates.test.ts
+++ b/__tests__/actions/sprint-dates.test.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
-vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
+vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts
index b391d0a..9451190 100644
--- a/actions/active-sprint.ts
+++ b/actions/active-sprint.ts
@@ -40,3 +40,24 @@ export async function setActiveSprintAction(productId: string, sprintId: string)
revalidatePath('/', 'layout')
return { success: true, sprintId: parsed.data.sprintId }
}
+
+export async function syncActiveSprintCookieAction(productId: string, sprintId: string) {
+ const session = await getSession()
+ if (!session.userId) return
+ if (session.isDemo) return
+
+ const parsed = setSchema.safeParse({ productId, sprintId })
+ if (!parsed.success) return
+
+ const sprint = await prisma.sprint.findFirst({
+ where: {
+ id: parsed.data.sprintId,
+ product_id: parsed.data.productId,
+ product: productAccessFilter(session.userId),
+ },
+ select: { id: true },
+ })
+ if (!sprint) return
+
+ await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId)
+}
diff --git a/actions/sprints.ts b/actions/sprints.ts
index dc5d55e..de096d3 100644
--- a/actions/sprints.ts
+++ b/actions/sprints.ts
@@ -40,6 +40,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
sprint_goal: formData.get('sprint_goal'),
start_date: formData.get('start_date'),
end_date: formData.get('end_date'),
+ pbi_id: formData.get('pbi_id'),
})
if (!parsed.success) {
return {
@@ -72,6 +73,36 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
}),
)
+ if (parsed.data.pbi_id) {
+ const pbi = await prisma.pbi.findFirst({
+ where: { id: parsed.data.pbi_id, product_id: parsed.data.productId },
+ select: { id: true },
+ })
+ if (pbi) {
+ const stories = await prisma.story.findMany({
+ where: { pbi_id: pbi.id, sprint_id: null },
+ orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
+ select: { id: true },
+ })
+ if (stories.length > 0) {
+ const storyIds = stories.map(s => s.id)
+ await prisma.$transaction([
+ ...stories.map((s, i) =>
+ prisma.story.update({
+ where: { id: s.id },
+ data: { sprint_id: sprint.id, status: 'IN_SPRINT', sort_order: i + 1 },
+ }),
+ ),
+ prisma.task.updateMany({
+ where: { story_id: { in: storyIds }, sprint_id: null },
+ data: { sprint_id: sprint.id },
+ }),
+ ])
+ }
+ }
+ }
+
+ await setActiveSprintCookie(parsed.data.productId, sprint.id)
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true, sprintId: sprint.id }
}
diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index d81c678..a8e79e5 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -3,8 +3,8 @@ import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
-import { pbiStatusToApi, sprintStatusToApi } from '@/lib/task-status'
-import { resolveActiveSprint } from '@/lib/active-sprint'
+import { pbiStatusToApi } from '@/lib/task-status'
+import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
@@ -35,41 +35,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
- const [allSprints, user, resolvedActiveSprint] = await Promise.all([
- prisma.sprint.findMany({
- where: { product_id: id },
- orderBy: { created_at: 'desc' },
- select: { id: true, code: true, status: true },
- }),
+ const [user, switcherData] = await Promise.all([
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
- resolveActiveSprint(id),
+ getSprintSwitcherData(id),
])
- const hasOpenSprint = allSprints.some(s => s.status === 'OPEN')
-
- let buildingSprintIds: string[] = []
- if (allSprints.length > 0) {
- const runs = await prisma.sprintRun.findMany({
- where: {
- sprint_id: { in: allSprints.map(s => s.id) },
- status: { in: ['QUEUED', 'RUNNING'] },
- },
- select: { sprint_id: true },
- })
- buildingSprintIds = Array.from(new Set(runs.map(r => r.sprint_id)))
- }
-
- const sprintItems = allSprints.map(s => ({
- id: s.id,
- code: s.code,
- status: sprintStatusToApi(s.status),
- }))
- const activeSprintItem = resolvedActiveSprint
- ? {
- id: resolvedActiveSprint.id,
- code: resolvedActiveSprint.code,
- status: sprintStatusToApi(resolvedActiveSprint.status),
- }
- : null
+ const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData
+ const hasOpenSprint = sprintItems.some(s => s.status === 'open')
const isActiveProduct = user?.active_product_id === id
const pbis = await prisma.pbi.findMany({
@@ -127,9 +98,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
return (
- {/* Product header — sprint-switcher links, actions rechts */}
-
-
+ {/* Product header — sprint-switcher gecentreerd, actions rechts */}
+
+
+
{isActiveProduct && (
)}
-
+
{!isActiveProduct && (
)}
diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx
index 72aa65b..8af037d 100644
--- a/app/(app)/products/[id]/solo/page.tsx
+++ b/app/(app)/products/[id]/solo/page.tsx
@@ -2,8 +2,10 @@ import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
+import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { SoloBoard } from '@/components/solo/solo-board'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
+import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import type { SoloTask } from '@/components/solo/solo-board'
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
@@ -23,9 +25,23 @@ export default async function SoloProductPage({ params }: Props) {
where: { product_id: id, status: 'OPEN' },
})
+ const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
+
+ const switcherBar = (
+
+
+
+ )
+
if (!sprint) {
return (
+ {switcherBar}
)
@@ -106,14 +122,19 @@ export default async function SoloProductPage({ params }: Props) {
}))
return (
-
+
)
}
diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
index bdac0d5..a04849d 100644
--- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
+++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx
@@ -4,8 +4,10 @@ import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
-import { setActiveSprintCookie } from '@/lib/active-sprint'
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
+import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie'
+import { SprintSwitcher } from '@/components/shared/sprint-switcher'
+import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { SprintHeader } from '@/components/sprint/sprint-header'
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
import { parsePauseContext } from '@/lib/pause-context'
@@ -48,7 +50,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
})
if (!sprint) notFound()
- await setActiveSprintCookie(id, sprint.id)
+ const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id })
const activeSprintRun = await prisma.sprintRun.findFirst({
where: {
@@ -158,6 +160,15 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
return (
diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx
index 1419a51..28edcd6 100644
--- a/components/shared/sprint-switcher.tsx
+++ b/components/shared/sprint-switcher.tsx
@@ -1,22 +1,22 @@
'use client'
import { usePathname, useRouter } from 'next/navigation'
-import { useTransition } from 'react'
-import { ChevronDown } from 'lucide-react'
+import { useState, useTransition } from 'react'
+import { Check, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
-import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { setActiveSprintAction } from '@/actions/active-sprint'
import type { SprintStatusApi } from '@/lib/task-status'
-type SprintItem = { id: string; code: string; status: SprintStatusApi }
+type SprintItem = { id: string; code: string; sprint_goal: string; status: SprintStatusApi }
interface SprintSwitcherProps {
productId: string
@@ -32,13 +32,6 @@ const SPRINT_STATUS_LABEL: Record
= {
failed: 'Mislukt',
}
-const SPRINT_STATUS_BADGE: Record = {
- open: 'bg-status-in-progress text-foreground',
- closed: 'bg-status-done text-foreground',
- archived: 'bg-surface-container text-muted-foreground',
- failed: 'bg-status-failed text-foreground',
-}
-
export function SprintSwitcher({
productId,
sprints,
@@ -48,8 +41,15 @@ export function SprintSwitcher({
const pathname = usePathname()
const router = useRouter()
const [isPending, startTransition] = useTransition()
+ const [showClosed, setShowClosed] = useState(false)
const buildingSet = new Set(buildingSprintIds)
+ const visibleSprints = sprints.filter(s => {
+ if (showClosed) return true
+ if (s.id === activeSprint?.id) return true
+ return s.status === 'open'
+ })
+
function handleSwitchSprint(sprintId: string) {
if (sprintId === activeSprint?.id) return
startTransition(async () => {
@@ -92,42 +92,64 @@ export function SprintSwitcher({
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
{activeSprint && (
-
{buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
-
+
)}
-
- {sprints.map(s => (
- handleSwitchSprint(s.id)}
+
+
+
+ {visibleSprints.length === 0 ? (
+
+ Geen open sprints
+
+ ) : (
+ visibleSprints.map(s => (
+ handleSwitchSprint(s.id)}
className={cn(
- 'text-[10px] px-1.5 py-0 shrink-0',
- buildingSet.has(s.id)
- ? 'bg-warning text-warning-foreground'
- : SPRINT_STATUS_BADGE[s.status],
+ 'flex items-center gap-2',
+ s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
)}
>
- {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
-
-
- ))}
+ {s.code}
+ {s.sprint_goal}
+
+ {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
+
+
+ ))
+ )}
)
diff --git a/components/shared/user-menu.tsx b/components/shared/user-menu.tsx
index 12e9b54..7283222 100644
--- a/components/shared/user-menu.tsx
+++ b/components/shared/user-menu.tsx
@@ -2,7 +2,7 @@
import { useTransition } from 'react'
import Link from 'next/link'
-import { Settings, Sun, Globe, LogOut } from 'lucide-react'
+import { Settings, Sun, Globe, LogOut, BookOpen, Shield } from 'lucide-react'
import { logoutAction } from '@/actions/auth'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
@@ -112,6 +112,20 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) {
+ }>
+
+ Manual
+
+
+ {roles.includes('ADMIN') && (
+ }>
+
+ Admin
+
+ )}
+
+
+
(null)
const router = useRouter()
+ const selectedPbiId = useSelectionStore((s) => s.selectedPbiId)
const [state, formAction, pending] = useActionState(
async (_prev, fd) => {
@@ -51,7 +53,7 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt
if (result?.success) {
setOpen(false)
setDirty(false)
- router.push(`/products/${productId}/sprint`)
+ router.refresh()
} else if (result?.code !== 422 && result?.error) {
// Toast handled by caller; here we just keep the form open
}
@@ -92,6 +94,7 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
+ {selectedPbiId && }