De "Nieuwe sprint"-knop rendert niet langer op een niet-actief product — een sprint-draft starten daar was verwarrend. page.tsx geeft de bestaande isActiveProduct-flag door. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
import { Suspense } from 'react'
|
||
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 { 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'
|
||
import type { Story } from '@/components/backlog/story-panel'
|
||
import { TaskPanel } from '@/components/backlog/task-panel'
|
||
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
||
import { UrlTaskSync } from '@/components/backlog/url-task-sync'
|
||
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 { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
|
||
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
|
||
import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard'
|
||
import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
|
||
import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator'
|
||
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 {
|
||
params: Promise<{ id: string }>
|
||
searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }>
|
||
}
|
||
|
||
export default async function ProductBacklogPage({ params, searchParams }: Props) {
|
||
const { id } = await params
|
||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||
const closePath = `/products/${id}`
|
||
const session = await getSession()
|
||
if (!session.userId) redirect('/login')
|
||
|
||
const product = await getAccessibleProduct(id, session.userId)
|
||
if (!product) notFound()
|
||
|
||
const [user, switcherData] = await Promise.all([
|
||
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
||
getSprintSwitcherData(id, { userId: session.userId }),
|
||
])
|
||
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({
|
||
where: { product_id: id },
|
||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||
})
|
||
|
||
const [stories, tasks] = await Promise.all([
|
||
prisma.story.findMany({
|
||
where: { product_id: id },
|
||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||
select: {
|
||
id: true,
|
||
code: true,
|
||
title: true,
|
||
description: true,
|
||
acceptance_criteria: true,
|
||
priority: true,
|
||
sort_order: true,
|
||
status: true,
|
||
pbi_id: true,
|
||
sprint_id: true,
|
||
created_at: true,
|
||
},
|
||
}),
|
||
prisma.task.findMany({
|
||
where: { story: { pbi: { product_id: id } } },
|
||
select: {
|
||
id: true,
|
||
code: true,
|
||
title: true,
|
||
description: true,
|
||
priority: true,
|
||
status: true,
|
||
sort_order: true,
|
||
story_id: true,
|
||
created_at: true,
|
||
},
|
||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||
}),
|
||
])
|
||
|
||
// Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad)
|
||
const storiesByPbi: Record<string, Story[]> = {}
|
||
for (const story of stories) {
|
||
if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = []
|
||
storiesByPbi[story.pbi_id].push(story)
|
||
}
|
||
|
||
// Group tasks by story id
|
||
const tasksByStory: Record<string, typeof tasks> = {}
|
||
for (const task of tasks) {
|
||
if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = []
|
||
tasksByStory[task.story_id].push(task)
|
||
}
|
||
|
||
const isDemo = session.isDemo ?? false
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Product header — sprint-switcher gecentreerd, actions rechts */}
|
||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center gap-3">
|
||
<div className="flex-1" />
|
||
<div className="flex items-center justify-center">
|
||
{isActiveProduct && (
|
||
<SprintSwitcher
|
||
productId={id}
|
||
sprints={sprintItems}
|
||
activeSprint={activeSprintItem}
|
||
buildingSprintIds={buildingSprintIds}
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 flex items-center gap-3 justify-end">
|
||
{!isActiveProduct && (
|
||
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
||
)}
|
||
{hasOpenSprint && (
|
||
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
||
Sprint actief →
|
||
</Link>
|
||
)}
|
||
{activeSprintItem && !isDemo && (
|
||
<SaveSprintButton activeSprintId={activeSprintItem.id} />
|
||
)}
|
||
{!isDemo && (
|
||
<NewSprintTrigger
|
||
productId={id}
|
||
isDemo={isDemo}
|
||
isActiveProduct={isActiveProduct}
|
||
/>
|
||
)}
|
||
{!isDemo && product.user_id === session.userId && (
|
||
<EditProductButton
|
||
product={{
|
||
id: product.id,
|
||
name: product.name,
|
||
code: product.code,
|
||
description: product.description,
|
||
repo_url: product.repo_url,
|
||
definition_of_done: product.definition_of_done,
|
||
auto_pr: product.auto_pr,
|
||
}}
|
||
/>
|
||
)}
|
||
<Link
|
||
href={`/products/${id}/settings`}
|
||
className="text-xs text-muted-foreground hover:text-foreground"
|
||
>
|
||
Instellingen
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sprint definition banner (state A′) + beforeunload-guard */}
|
||
<SprintDraftBanner productId={id} />
|
||
<SprintDraftLeaveGuard productId={id} />
|
||
|
||
{/* Split pane */}
|
||
<div className="flex-1 overflow-hidden">
|
||
<BacklogHydrationWrapper
|
||
productId={id}
|
||
productName={product.name}
|
||
initialData={{
|
||
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
||
storiesByPbi,
|
||
tasksByStory,
|
||
}}
|
||
>
|
||
<UrlTaskSync />
|
||
<ActiveSelectionHydrator productId={id} />
|
||
<BacklogSplitPane
|
||
cookieKey={`backlog-${id}`}
|
||
defaultSplit={[20, 45, 35]}
|
||
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
||
panes={[
|
||
<PbiList
|
||
key="pbi"
|
||
productId={id}
|
||
isDemo={isDemo}
|
||
activeSprintId={activeSprintItem?.id ?? null}
|
||
/>,
|
||
<StoryPanel
|
||
key="story"
|
||
productId={id}
|
||
isDemo={isDemo}
|
||
activeSprintId={activeSprintItem?.id ?? null}
|
||
/>,
|
||
<TaskPanel
|
||
key="tasks"
|
||
productId={id}
|
||
isDemo={isDemo}
|
||
closePath={closePath}
|
||
/>,
|
||
]}
|
||
/>
|
||
</BacklogHydrationWrapper>
|
||
</div>
|
||
|
||
{newTask && (
|
||
<TaskDialog
|
||
storyId={storyIdParam}
|
||
productId={id}
|
||
closePath={closePath}
|
||
isDemo={isDemo}
|
||
/>
|
||
)}
|
||
|
||
{editTask && !newTask && (
|
||
<Suspense fallback={<TaskDialogSkeleton />}>
|
||
<EditTaskLoader
|
||
taskId={editTask}
|
||
userId={session.userId}
|
||
productId={id}
|
||
closePath={closePath}
|
||
isDemo={isDemo}
|
||
/>
|
||
</Suspense>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|