* 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) <noreply@anthropic.com> * chore: sync package-lock.json version naar 1.2.0 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
233 lines
7.7 KiB
TypeScript
233 lines
7.7 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, 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'
|
|
import type { Story } from '@/components/backlog/story-panel'
|
|
import { TaskPanel } from '@/components/backlog/task-panel'
|
|
import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper'
|
|
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 { 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 [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 },
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
})
|
|
|
|
const [stories, tasks] = await Promise.all([
|
|
prisma.story.findMany({
|
|
where: { product_id: id },
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
title: true,
|
|
description: true,
|
|
acceptance_criteria: true,
|
|
priority: true,
|
|
status: true,
|
|
pbi_id: true,
|
|
created_at: true,
|
|
},
|
|
}),
|
|
prisma.task.findMany({
|
|
where: { story: { pbi: { product_id: id } } },
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
description: true,
|
|
priority: true,
|
|
status: true,
|
|
sort_order: true,
|
|
story_id: true,
|
|
created_at: true,
|
|
},
|
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
|
}),
|
|
])
|
|
|
|
// Group stories by PBI id
|
|
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 links, actions rechts */}
|
|
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
{isActiveProduct && (
|
|
<SprintSwitcher
|
|
productId={id}
|
|
sprints={sprintItems}
|
|
activeSprint={activeSprintItem}
|
|
buildingSprintIds={buildingSprintIds}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{!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>
|
|
) : (
|
|
!isDemo && <StartSprintButton productId={id} />
|
|
)}
|
|
{!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>
|
|
|
|
{/* Split pane */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<BacklogHydrationWrapper
|
|
productId={id}
|
|
initialData={{
|
|
pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })),
|
|
storiesByPbi,
|
|
tasksByStory,
|
|
}}
|
|
>
|
|
<BacklogSplitPane
|
|
cookieKey={`backlog-${id}`}
|
|
defaultSplit={[20, 45, 35]}
|
|
tabLabels={['PBI\'s', 'Stories', 'Taken']}
|
|
panes={[
|
|
<PbiList
|
|
key="pbi"
|
|
productId={id}
|
|
isDemo={isDemo}
|
|
/>,
|
|
<StoryPanel
|
|
key="story"
|
|
productId={id}
|
|
isDemo={isDemo}
|
|
/>,
|
|
<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>
|
|
)
|
|
}
|