From 067c48c4368ebb1c0d7d877835fd9e8bbcbe0d14 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 01:01:41 +0200 Subject: [PATCH] refactor: verplaats sprint-switcher van NavBar naar product-header Sprint-pulldown zit nu in de bestaande balk op de product backlog (naast Sprint starten / Instellingen) i.p.v. in het midden van de NavBar. Alleen zichtbaar wanneer het product ook het actieve product van de gebruiker is. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/layout.tsx | 33 +------ app/(app)/products/[id]/page.tsx | 58 +++++++++-- components/shared/nav-bar.tsx | 115 +--------------------- components/shared/sprint-switcher.tsx | 134 ++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 147 deletions(-) create mode 100644 components/shared/sprint-switcher.tsx 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]} + + + ))} + + + ) +}