From b7033c40ae30d9d6fc8ad4008712914c9487da72 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Mon, 27 Apr 2026 19:14:43 +0200 Subject: [PATCH] feat(ST-904): split NavBar into 5 tabs with disabled-states and product-switcher dropdown Co-Authored-By: Claude Sonnet 4.6 --- app/(app)/layout.tsx | 15 ++- components/shared/nav-bar.tsx | 189 ++++++++++++++++++++++++---------- 2 files changed, 150 insertions(+), 54 deletions(-) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index e14ad47..23c03fc 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -16,7 +16,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod redirect('/login') } - const [user, userRoles] = await Promise.all([ + const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, select: { username: true, email: true, active_product_id: true }, @@ -25,6 +25,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod where: { user_id: session.userId }, select: { role: true }, }), + prisma.product.findMany({ + where: { archived: false, ...productAccessFilter(session.userId) }, + orderBy: { name: 'asc' }, + select: { id: true, name: true }, + }), ]) const roles = userRoles.map(r => r.role as string) @@ -34,6 +39,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 hasActiveSprint = false if (user.active_product_id) { const product = await prisma.product.findFirst({ where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) }, @@ -41,6 +47,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product + const sprint = await prisma.sprint.findFirst({ + where: { product_id: product.id, status: 'ACTIVE' }, + select: { id: true }, + }) + hasActiveSprint = !!sprint } else { await prisma.user.update({ where: { id: session.userId }, @@ -61,6 +72,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod username={user.username} email={user.email} activeProduct={activeProduct} + products={accessibleProducts} + hasActiveSprint={hasActiveSprint} />
diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index f7cc8a8..9f38668 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -1,13 +1,23 @@ 'use client' import Link from 'next/link' -import { usePathname } from 'next/navigation' +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, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { AppIcon } from '@/components/shared/app-icon' import { UserMenu } from '@/components/shared/user-menu' import { cn } from '@/lib/utils' -import { useProductStore } from '@/stores/product-store' +import { setActiveProductAction } from '@/actions/active-product' interface NavBarProps { isDemo: boolean @@ -16,23 +26,83 @@ interface NavBarProps { username: string email: string | null activeProduct: { id: string; name: string } | null + products: { id: string; name: string }[] + hasActiveSprint: boolean } -export function NavBar({ isDemo, roles, userId, username, email, activeProduct: _activeProduct }: NavBarProps) { +export function NavBar({ + isDemo, + roles, + userId, + username, + email, + activeProduct, + products, + hasActiveSprint, +}: NavBarProps) { const pathname = usePathname() - const currentProduct = useProductStore(s => s.currentProduct) + const router = useRouter() + const [isPending, startTransition] = useTransition() - const productMatch = pathname.match(/^\/products\/([^/]+)/) - const productId = productMatch ? productMatch[1] : null + function handleSwitchProduct(productId: string) { + startTransition(async () => { + const result = await setActiveProductAction(productId) + if (result?.error) { + toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') + } else { + router.push(`/products/${productId}`) + } + }) + } - const sprintHref = productId ? `/products/${productId}/sprint` : null + const activeId = activeProduct?.id ?? null - const navLinks = [ - { href: '/dashboard', label: 'Producten', active: pathname.startsWith('/dashboard') || (pathname.startsWith('/products') && !pathname.includes('/solo')) }, - { href: sprintHref, label: 'Sprint', active: pathname.includes('/sprint') }, - { href: '/solo', label: 'Solo', active: pathname.includes('/solo') }, - { href: '/todos', label: "Todo's", active: pathname.startsWith('/todos') }, - ] + // Nav link helpers + const disabledSpan = (label: string) => ( + + {label} + + ) + + const navLink = (href: string, label: string, isActive: boolean) => ( + + {label} + + ) + + const sprintNode = () => { + if (!activeId) return disabledSpan('Sprint') + const href = `/products/${activeId}/sprint` + const isActive = pathname.includes('/sprint') + if (!hasActiveSprint) { + return ( + + + + Sprint + + Geen actieve sprint + + + ) + } + return navLink(href, 'Sprint', isActive) + } return (
@@ -49,50 +119,63 @@ export function NavBar({ isDemo, roles, userId, username, email, activeProduct: - {/* Midden: productnaam */} + {/* Midden: actief product dropdown */}
- {currentProduct && ( - - - - }> - {currentProduct.name.length > 20 - ? currentProduct.name.slice(0, 20) + '…' - : currentProduct.name} - - {currentProduct.name} - - + {activeProduct ? ( + + + + {activeProduct.name.length > 22 + ? activeProduct.name.slice(0, 22) + '…' + : activeProduct.name} + + + + + {products.map(p => ( + p.id !== activeProduct.id && handleSwitchProduct(p.id)} + className={cn( + p.id === activeProduct.id && 'bg-primary-container text-primary-container-foreground font-medium' + )} + > + {p.name} + + ))} + + + + Producten beheren → + + + + + ) : ( + Geen actief product )}