From 125855c603063ecc00b9bd0040e9f3d1c90e0a25 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 00:24:40 +0200 Subject: [PATCH] fix(PBI-63): sprint-switcher naar productpagina-header in productstijl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verwijdert setActiveSprintCookie uit sprint board page (kon geen cookie schrijven vanuit een Server Component — alleen Server Action / Route Handler) - Verplaatst sprint-pulldown uit NavBar naar de productpagina action-row (dezelfde rij als 'Sprint starten' / 'Instellingen') - Nieuwe SprintSwitcher-component in zelfde stijl als product-dropdown: truncated tekst + ChevronDown, geen status-badges - NavBar krijgt activeSprintId voor sprint-link href; layout fetcht alleen nog activeSprint-id (geen volledige sprints[] meer) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/layout.tsx | 36 +----- app/(app)/products/[id]/page.tsx | 26 ++-- .../products/[id]/sprint/[sprintId]/page.tsx | 3 - components/shared/nav-bar.tsx | 121 ++---------------- components/sprint/sprint-switcher.tsx | 84 ++++++++++++ 5 files changed, 118 insertions(+), 152 deletions(-) create mode 100644 components/sprint/sprint-switcher.tsx diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 806d725..d3f2b10 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,8 @@ 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 activeSprintId: string | null = null + 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 +46,9 @@ 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))) - } + activeSprintId = resolved?.id ?? null + hasActiveSprint = !!resolved } else { await prisma.user.update({ where: { id: session.userId }, @@ -94,9 +71,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod email={user.email} activeProduct={activeProduct} products={accessibleProducts} - sprints={sprints} - activeSprint={activeSprint} - buildingSprintIds={buildingSprintIds} + hasActiveSprint={hasActiveSprint} + activeSprintId={activeSprintId} minQuotaPct={user.min_quota_pct} /> diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index a91b8f1..7d1d172 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -14,8 +14,10 @@ import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { SprintSwitcher } from '@/components/sprint/sprint-switcher' import { ActivateProductButton } from '@/components/shared/activate-product-button' import { EditProductButton } from '@/components/products/edit-product-button' +import { resolveActiveSprint } from '@/lib/active-sprint' import Link from 'next/link' interface Props { @@ -33,9 +35,14 @@ 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, activeSprint] = await Promise.all([ + prisma.sprint.findMany({ + where: { product_id: id }, + orderBy: { created_at: 'desc' }, + select: { id: true, code: true }, + }), prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), + resolveActiveSprint(id), ]) const pbis = await prisma.pbi.findMany({ @@ -99,12 +106,15 @@ export default async function ProductBacklogPage({ params, searchParams }: Props {user?.active_product_id !== id && ( )} - {activeSprint ? ( - - Sprint actief → - - ) : ( - !isDemo && + {allSprints.length > 0 && ( + + )} + {!isDemo && !activeSprint && ( + )} {!isDemo && product.user_id === session.userId && ( = { - 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 +42,13 @@ export function NavBar({ email, activeProduct, products, - sprints, - activeSprint, - buildingSprintIds, + hasActiveSprint, + activeSprintId, 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 +63,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 +108,9 @@ export function NavBar({ ) } - const href = `/products/${activeId}/sprint/${activeSprint!.id}` + const href = activeSprintId + ? `/products/${activeId}/sprint/${activeSprintId}` + : `/products/${activeId}/sprint` return navLink(href, 'Sprint', isActive) } @@ -190,8 +153,8 @@ export function NavBar({ - {/* Midden: actief product + sprint, gestapeld */} -
+ {/* Midden: actief product dropdown */} +
{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/sprint/sprint-switcher.tsx b/components/sprint/sprint-switcher.tsx new file mode 100644 index 0000000..6f5fbbd --- /dev/null +++ b/components/sprint/sprint-switcher.tsx @@ -0,0 +1,84 @@ +'use client' + +import { useTransition } from 'react' +import { usePathname, useRouter } from 'next/navigation' +import { ChevronDown } from 'lucide-react' +import { toast } from 'sonner' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' +import { setActiveSprintAction } from '@/actions/active-sprint' + +type SprintItem = { id: string; code: string } + +interface SprintSwitcherProps { + productId: string + sprints: SprintItem[] + activeSprintId: string | null + className?: string +} + +export function SprintSwitcher({ + productId, + sprints, + activeSprintId, + className, +}: SprintSwitcherProps) { + const router = useRouter() + const pathname = usePathname() + const [isPending, startTransition] = useTransition() + + if (sprints.length === 0) return null + + const active = sprints.find(s => s.id === activeSprintId) ?? sprints[0] + + function handleSwitch(sprintId: string) { + if (sprintId === active.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() + } + }) + } + + return ( + + + + {active.code.length > 22 ? active.code.slice(0, 22) + '…' : active.code} + + + + + {sprints.map(s => ( + handleSwitch(s.id)} + className={cn( + s.id === active.id && 'bg-primary-container text-primary-container-foreground font-medium', + )} + > + {s.code} + + ))} + + + ) +}