diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index 806d725..9271c4c 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -3,7 +3,6 @@ import { requireSession } from '@/lib/auth-guard'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { resolveActiveSprint } from '@/lib/active-sprint'
-import { sprintStatusToApi, type SprintStatusApi } from '@/lib/task-status'
import { NavBar } from '@/components/shared/nav-bar'
import { MinWidthBanner } from '@/components/shared/min-width-banner'
import { StatusBar } from '@/components/shared/status-bar'
@@ -38,9 +37,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
// Resolve active product — clear stale reference if archived or inaccessible
let activeProduct: { id: string; name: string } | null = null
- let sprints: { id: string; code: string; status: SprintStatusApi }[] = []
- let activeSprint: { id: string; code: string; status: SprintStatusApi } | null = null
- let buildingSprintIds: string[] = []
+ let hasActiveSprint = false
if (user.active_product_id) {
const product = await prisma.product.findFirst({
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
@@ -48,30 +45,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
})
if (product) {
activeProduct = product
- const allSprints = await prisma.sprint.findMany({
- where: { product_id: product.id },
- orderBy: { created_at: 'desc' },
- select: { id: true, code: true, status: true },
- })
- sprints = allSprints.map(s => ({
- id: s.id,
- code: s.code,
- status: sprintStatusToApi(s.status),
- }))
const resolved = await resolveActiveSprint(product.id)
- activeSprint = resolved
- ? { id: resolved.id, code: resolved.code, status: sprintStatusToApi(resolved.status) }
- : null
- 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)))
- }
+ hasActiveSprint = !!resolved
} else {
await prisma.user.update({
where: { id: session.userId },
@@ -94,9 +69,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
email={user.email}
activeProduct={activeProduct}
products={accessibleProducts}
- sprints={sprints}
- activeSprint={activeSprint}
- buildingSprintIds={buildingSprintIds}
+ hasActiveSprint={hasActiveSprint}
minQuotaPct={user.min_quota_pct}
/>
diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index a91b8f1..d81c678 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -3,7 +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 } from '@/lib/task-status'
+import { pbiStatusToApi, sprintStatusToApi } from '@/lib/task-status'
+import { resolveActiveSprint } from '@/lib/active-sprint'
import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
@@ -16,6 +17,7 @@ import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { EditProductButton } from '@/components/products/edit-product-button'
+import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import Link from 'next/link'
interface Props {
@@ -33,10 +35,42 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
- const [activeSprint, user] = await Promise.all([
- prisma.sprint.findFirst({ where: { product_id: id, status: 'OPEN' } }),
+ 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 },
+ }),
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
+ resolveActiveSprint(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 isActiveProduct = user?.active_product_id === id
const pbis = await prisma.pbi.findMany({
where: { product_id: id },
@@ -93,13 +127,23 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
return (
- {/* Product header — actions only; product-naam zit al in NavBar */}
-
+ {/* Product header — sprint-switcher links, actions rechts */}
+
- {user?.active_product_id !== id && (
+ {isActiveProduct && (
+
+ )}
+
+
+ {!isActiveProduct && (
)}
- {activeSprint ? (
+ {hasOpenSprint ? (
Sprint actief →
diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx
index 041a22d..0f280be 100644
--- a/components/shared/nav-bar.tsx
+++ b/components/shared/nav-bar.tsx
@@ -20,10 +20,6 @@ import { NotificationsBell } from '@/components/shared/notifications-bell'
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
import { cn } from '@/lib/utils'
import { setActiveProductAction } from '@/actions/active-product'
-import { setActiveSprintAction } from '@/actions/active-sprint'
-import type { SprintStatusApi } from '@/lib/task-status'
-
-type SprintItem = { id: string; code: string; status: SprintStatusApi }
interface NavBarProps {
isDemo: boolean
@@ -33,26 +29,10 @@ interface NavBarProps {
email: string | null
activeProduct: { id: string; name: string } | null
products: { id: string; name: string }[]
- sprints: SprintItem[]
- activeSprint: SprintItem | null
- buildingSprintIds: string[]
+ hasActiveSprint: boolean
minQuotaPct: number
}
-const SPRINT_STATUS_LABEL: Record
= {
- open: 'Open',
- closed: 'Gesloten',
- archived: 'Gearchiveerd',
- 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 NavBar({
isDemo,
roles,
@@ -61,16 +41,12 @@ export function NavBar({
email,
activeProduct,
products,
- sprints,
- activeSprint,
- buildingSprintIds,
+ hasActiveSprint,
minQuotaPct,
}: NavBarProps) {
const pathname = usePathname()
const router = useRouter()
const [isPending, startTransition] = useTransition()
- const buildingSet = new Set(buildingSprintIds)
- const hasActiveSprint = !!activeSprint
function handleSwitchProduct(productId: string) {
startTransition(async () => {
@@ -85,23 +61,6 @@ export function NavBar({
})
}
- function handleSwitchSprint(sprintId: string) {
- if (!activeProduct) return
- if (sprintId === activeSprint?.id) return
- startTransition(async () => {
- const result = await setActiveSprintAction(activeProduct.id, sprintId)
- if (result?.error) {
- toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
- return
- }
- if (pathname.includes('/sprint')) {
- router.push(`/products/${activeProduct.id}/sprint/${sprintId}`)
- } else {
- router.refresh()
- }
- })
- }
-
const activeId = activeProduct?.id ?? null
// Nav link helpers
@@ -147,7 +106,7 @@ export function NavBar({
)
}
- const href = `/products/${activeId}/sprint/${activeSprint!.id}`
+ const href = `/products/${activeId}/sprint`
return navLink(href, 'Sprint', isActive)
}
@@ -190,8 +149,8 @@ export function NavBar({
- {/* Midden: actief product + sprint, gestapeld */}
-
+ {/* Midden: actief product */}
+
{activeProduct ? (
Geen actief product
)}
-
- {activeProduct && (
- sprints.length === 0 ? (
-
-
-
- Geen sprints
-
- Maak een sprint aan vanuit de Product Backlog
-
-
- ) : (
-
-
-
- {activeSprint ? activeSprint.code : 'Selecteer sprint'}
-
- {activeSprint && (
-
- {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
-
- )}
-
-
-
- {sprints.map(s => (
- handleSwitchSprint(s.id)}
- className={cn(
- 'flex items-center justify-between gap-2',
- s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
- )}
- >
- {s.code}
-
- {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
-
-
- ))}
-
-
- )
- )}
{/* Rechts: solo-status + notifications + account-menu */}
diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx
new file mode 100644
index 0000000..1419a51
--- /dev/null
+++ b/components/shared/sprint-switcher.tsx
@@ -0,0 +1,134 @@
+'use client'
+
+import { usePathname, useRouter } from 'next/navigation'
+import { useTransition } from 'react'
+import { 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,
+ 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 }
+
+interface SprintSwitcherProps {
+ productId: string
+ sprints: SprintItem[]
+ activeSprint: SprintItem | null
+ buildingSprintIds: string[]
+}
+
+const SPRINT_STATUS_LABEL: Record
= {
+ open: 'Open',
+ closed: 'Gesloten',
+ archived: 'Gearchiveerd',
+ 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,
+ activeSprint,
+ buildingSprintIds,
+}: SprintSwitcherProps) {
+ const pathname = usePathname()
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const buildingSet = new Set(buildingSprintIds)
+
+ function handleSwitchSprint(sprintId: string) {
+ if (sprintId === activeSprint?.id) return
+ startTransition(async () => {
+ const result = await setActiveSprintAction(productId, sprintId)
+ if (result?.error) {
+ toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
+ return
+ }
+ if (pathname.includes('/sprint')) {
+ router.push(`/products/${productId}/sprint/${sprintId}`)
+ } else {
+ router.refresh()
+ }
+ })
+ }
+
+ if (sprints.length === 0) {
+ return (
+
+
+
+ Geen sprints
+
+ Maak een sprint aan vanuit de Product Backlog
+
+
+ )
+ }
+
+ return (
+
+
+
+ {activeSprint ? activeSprint.code : 'Selecteer sprint'}
+
+ {activeSprint && (
+
+ {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
+
+ )}
+
+
+
+ {sprints.map(s => (
+ handleSwitchSprint(s.id)}
+ className={cn(
+ 'flex items-center justify-between gap-2',
+ s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
+ )}
+ >
+ {s.code}
+
+ {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
+
+
+ ))}
+
+
+ )
+}