* feat: add plan_snapshot field to ClaudeJob schema Nullable String? column on claude_jobs captures the task's implementation_plan at claim time — immutable baseline for drift detection. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: update ClaudeJob lifecycle with plan_snapshot Document state machine snapshot capture/reset, plan_snapshot field rationale, and drift-detection baseline semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: remove duplicate header labels on backlog page Both the product H1 + description in the page header and the "Product Backlog" panel-title in the PBI panel duplicated info already visible in the NavBar. Removed both, keeping the right-aligned action bars (activate/sprint/settings, plus filters/+PBI) intact. PanelNavBar component is unchanged — Stories and Taken panels keep their titles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
175 lines
5.7 KiB
TypeScript
175 lines
5.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 } from '@/lib/task-status'
|
|
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 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 [activeSprint, user] = await Promise.all([
|
|
prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }),
|
|
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
|
])
|
|
|
|
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 — actions only; product-naam zit al in NavBar */}
|
|
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-end">
|
|
<div className="flex items-center gap-3">
|
|
{user?.active_product_id !== id && (
|
|
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
|
|
)}
|
|
{activeSprint ? (
|
|
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
|
|
Sprint actief →
|
|
</Link>
|
|
) : (
|
|
!isDemo && <StartSprintButton productId={id} />
|
|
)}
|
|
<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>
|
|
)
|
|
}
|