feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher (#161)
- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden) - TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter) - Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts) - Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page - NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie - Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction) - Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite) - Version bump 1.0.0 → 1.2.0 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d68aa1e5e6
commit
4a9db57e94
43 changed files with 966 additions and 290 deletions
|
|
@ -57,7 +57,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
getBurndownData(userId),
|
||||
getSprintStatusBreakdown(userId),
|
||||
prisma.sprint.findMany({
|
||||
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
||||
where: { status: 'OPEN', product: productAccessFilter(userId) },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import { redirect } from 'next/navigation'
|
|||
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'
|
||||
|
|
@ -36,7 +38,9 @@ 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
|
||||
let sprints: { id: string; code: string; status: SprintStatusApi }[] = []
|
||||
let activeSprint: { id: string; code: string; status: SprintStatusApi } | null = null
|
||||
let buildingSprintIds: string[] = []
|
||||
if (user.active_product_id) {
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
|
||||
|
|
@ -44,11 +48,30 @@ 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 },
|
||||
const allSprints = await prisma.sprint.findMany({
|
||||
where: { product_id: product.id },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, code: true, status: true },
|
||||
})
|
||||
hasActiveSprint = !!sprint
|
||||
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)))
|
||||
}
|
||||
} else {
|
||||
await prisma.user.update({
|
||||
where: { id: session.userId },
|
||||
|
|
@ -71,7 +94,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
email={user.email}
|
||||
activeProduct={activeProduct}
|
||||
products={accessibleProducts}
|
||||
hasActiveSprint={hasActiveSprint}
|
||||
sprints={sprints}
|
||||
activeSprint={activeSprint}
|
||||
buildingSprintIds={buildingSprintIds}
|
||||
minQuotaPct={user.min_quota_pct}
|
||||
/>
|
||||
<MinWidthBanner />
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
if (!product) notFound()
|
||||
|
||||
const [activeSprint, user] = await Promise.all([
|
||||
prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }),
|
||||
prisma.sprint.findFirst({ where: { product_id: id, status: 'OPEN' } }),
|
||||
prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }),
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default async function SoloProductPage({ params }: Props) {
|
|||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
where: { product_id: id, status: 'OPEN' },
|
||||
})
|
||||
|
||||
if (!sprint) {
|
||||
|
|
|
|||
223
app/(app)/products/[id]/sprint/[sprintId]/page.tsx
Normal file
223
app/(app)/products/[id]/sprint/[sprintId]/page.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
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 { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
||||
import { parsePauseContext } from '@/lib/pause-context'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
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 Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string; sprintId: string }>
|
||||
searchParams: Promise<{
|
||||
newTask?: string
|
||||
storyId?: string
|
||||
editTask?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||
const { id, sprintId } = await params
|
||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { id: sprintId, product_id: id },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
},
|
||||
})
|
||||
if (!sprint) notFound()
|
||||
|
||||
await setActiveSprintCookie(id, sprint.id)
|
||||
|
||||
const activeSprintRun = await prisma.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id: sprint.id,
|
||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
||||
},
|
||||
select: { id: true, status: true, pause_context: true },
|
||||
orderBy: { created_at: 'desc' },
|
||||
})
|
||||
const pauseContext =
|
||||
activeSprintRun?.status === 'PAUSED'
|
||||
? parsePauseContext(activeSprintRun.pause_context)
|
||||
: null
|
||||
|
||||
// Sprint stories with full task data and assignee
|
||||
const [sprintStories, productMembers] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.productMember.findMany({
|
||||
where: { product_id: id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// All members who can be assigned: owner + product members
|
||||
const members: ProductMember[] = [
|
||||
{ userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' },
|
||||
...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })),
|
||||
]
|
||||
|
||||
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
status: s.status,
|
||||
taskCount: s.tasks.length,
|
||||
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
|
||||
assignee_id: s.assignee_id,
|
||||
assignee_username: s.assignee?.username ?? null,
|
||||
}))
|
||||
|
||||
const tasksByStory: Record<string, Task[]> = {}
|
||||
for (const story of sprintStories) {
|
||||
tasksByStory[story.id] = story.tasks.map(t => ({
|
||||
id: t.id,
|
||||
code: t.code,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
story_id: t.story_id,
|
||||
sprint_id: t.sprint_id,
|
||||
}))
|
||||
}
|
||||
|
||||
// All PBIs with their stories for the left (product backlog) panel
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
include: {
|
||||
stories: {
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const pbisWithStories: PbiWithStories[] = pbis
|
||||
.filter(pbi => pbi.stories.length > 0)
|
||||
.map(pbi => ({
|
||||
id: pbi.id,
|
||||
code: pbi.code,
|
||||
title: pbi.title,
|
||||
priority: pbi.priority,
|
||||
status: pbiStatusToApi(pbi.status),
|
||||
description: pbi.description,
|
||||
stories: pbi.stories.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
status: s.status,
|
||||
taskCount: 0,
|
||||
doneCount: 0,
|
||||
assignee_id: null,
|
||||
assignee_username: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
const sprintStoryIdList = sprintStories.map(s => s.id)
|
||||
const isDemo = session.isDemo ?? false
|
||||
const closePath = `/products/${id}/sprint/${sprint.id}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SprintHeader
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
sprint={sprint}
|
||||
isDemo={isDemo}
|
||||
sprintStories={sprintStoryItems}
|
||||
/>
|
||||
|
||||
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
||||
<SprintRunControls
|
||||
sprintId={sprint.id}
|
||||
productId={id}
|
||||
sprintStatus={sprint.status}
|
||||
activeSprintRunId={activeSprintRun?.id ?? null}
|
||||
activeSprintRunStatus={activeSprintRun?.status ?? null}
|
||||
pauseContext={pauseContext}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SprintBoardClient
|
||||
productId={id}
|
||||
sprintId={sprint.id}
|
||||
stories={sprintStoryItems}
|
||||
pbisWithStories={pbisWithStories}
|
||||
sprintStoryIdList={sprintStoryIdList}
|
||||
tasksByStory={tasksByStory}
|
||||
isDemo={isDemo}
|
||||
currentUserId={session.userId}
|
||||
members={members}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
|
||||
<Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Product Backlog
|
||||
</Link>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { redirect } from 'next/navigation'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
params: Promise<{ id: string; sprintId: string }>
|
||||
}
|
||||
|
||||
export default async function SprintPlanningRedirect({ params }: Props) {
|
||||
const { id } = await params
|
||||
redirect(`/products/${id}/sprint`)
|
||||
const { id, sprintId } = await params
|
||||
redirect(`/products/${id}/sprint/${sprintId}`)
|
||||
}
|
||||
|
|
@ -1,220 +1,15 @@
|
|||
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 { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
||||
import { parsePauseContext } from '@/lib/pause-context'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
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 Link from 'next/link'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{
|
||||
newTask?: string
|
||||
storyId?: string
|
||||
editTask?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||
export default async function SprintRedirectPage({ params }: Props) {
|
||||
const { id } = await params
|
||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
|
||||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
},
|
||||
})
|
||||
if (!sprint) redirect(`/products/${id}`)
|
||||
|
||||
const activeSprintRun = await prisma.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id: sprint.id,
|
||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
||||
},
|
||||
select: { id: true, status: true, pause_context: true },
|
||||
orderBy: { created_at: 'desc' },
|
||||
})
|
||||
const pauseContext =
|
||||
activeSprintRun?.status === 'PAUSED'
|
||||
? parsePauseContext(activeSprintRun.pause_context)
|
||||
: null
|
||||
|
||||
// Sprint stories with full task data and assignee
|
||||
const [sprintStories, productMembers] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id },
|
||||
orderBy: { sort_order: 'asc' },
|
||||
include: {
|
||||
tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.productMember.findMany({
|
||||
where: { product_id: id },
|
||||
include: { user: { select: { id: true, username: true } } },
|
||||
}),
|
||||
])
|
||||
|
||||
// All members who can be assigned: owner + product members
|
||||
const members: ProductMember[] = [
|
||||
{ userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' },
|
||||
...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })),
|
||||
]
|
||||
|
||||
const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
status: s.status,
|
||||
taskCount: s.tasks.length,
|
||||
doneCount: s.tasks.filter(t => t.status === 'DONE').length,
|
||||
assignee_id: s.assignee_id,
|
||||
assignee_username: s.assignee?.username ?? null,
|
||||
}))
|
||||
|
||||
const tasksByStory: Record<string, Task[]> = {}
|
||||
for (const story of sprintStories) {
|
||||
tasksByStory[story.id] = story.tasks.map(t => ({
|
||||
id: t.id,
|
||||
code: t.code,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
story_id: t.story_id,
|
||||
sprint_id: t.sprint_id,
|
||||
}))
|
||||
const active = await resolveActiveSprint(id)
|
||||
if (!active) {
|
||||
redirect(`/products/${id}?alert=no_sprint`)
|
||||
}
|
||||
|
||||
// All PBIs with their stories for the left (product backlog) panel
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { product_id: id },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
include: {
|
||||
stories: {
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const pbisWithStories: PbiWithStories[] = pbis
|
||||
.filter(pbi => pbi.stories.length > 0)
|
||||
.map(pbi => ({
|
||||
id: pbi.id,
|
||||
code: pbi.code,
|
||||
title: pbi.title,
|
||||
priority: pbi.priority,
|
||||
status: pbiStatusToApi(pbi.status),
|
||||
description: pbi.description,
|
||||
stories: pbi.stories.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
acceptance_criteria: s.acceptance_criteria,
|
||||
pbi_id: s.pbi_id,
|
||||
created_at: s.created_at,
|
||||
priority: s.priority,
|
||||
status: s.status,
|
||||
taskCount: 0,
|
||||
doneCount: 0,
|
||||
assignee_id: null,
|
||||
assignee_username: null,
|
||||
})),
|
||||
}))
|
||||
|
||||
const sprintStoryIdList = sprintStories.map(s => s.id)
|
||||
const isDemo = session.isDemo ?? false
|
||||
const closePath = `/products/${id}/sprint`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<SprintHeader
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
sprint={sprint}
|
||||
isDemo={isDemo}
|
||||
sprintStories={sprintStoryItems}
|
||||
/>
|
||||
|
||||
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
||||
<SprintRunControls
|
||||
sprintId={sprint.id}
|
||||
productId={id}
|
||||
sprintStatus={sprint.status}
|
||||
activeSprintRunId={activeSprintRun?.id ?? null}
|
||||
activeSprintRunStatus={activeSprintRun?.status ?? null}
|
||||
pauseContext={pauseContext}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SprintBoardClient
|
||||
productId={id}
|
||||
sprintId={sprint.id}
|
||||
stories={sprintStoryItems}
|
||||
pbisWithStories={pbisWithStories}
|
||||
sprintStoryIdList={sprintStoryIdList}
|
||||
tasksByStory={tasksByStory}
|
||||
isDemo={isDemo}
|
||||
currentUserId={session.userId}
|
||||
members={members}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0">
|
||||
<Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground">
|
||||
← Product Backlog
|
||||
</Link>
|
||||
</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>
|
||||
)
|
||||
redirect(`/products/${id}/sprint/${active.id}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
|||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
where: { product_id: id, status: 'OPEN' },
|
||||
})
|
||||
|
||||
if (!sprint) {
|
||||
|
|
|
|||
|
|
@ -15,10 +15,11 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
|
|||
REVIEW: { label: 'Review', dot: 'bg-status-review' },
|
||||
DONE: { label: 'Klaar', dot: 'bg-status-done' },
|
||||
FAILED: { label: 'Gefaald', dot: 'bg-status-failed' },
|
||||
EXCLUDED: { label: 'Uitgesloten', dot: 'bg-muted-foreground/40' },
|
||||
}
|
||||
|
||||
// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar.
|
||||
const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
|
||||
const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'EXCLUDED']
|
||||
|
||||
function StatusIndicator({ status }: { status: TaskStatus }) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export async function GET(
|
|||
|
||||
const [activeSprint, openIdeas] = await Promise.all([
|
||||
prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
where: { product_id: id, status: 'OPEN' },
|
||||
select: { id: true, sprint_goal: true, status: true },
|
||||
}),
|
||||
prisma.idea.findMany({
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export async function GET(
|
|||
const { id } = await params
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) },
|
||||
where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) },
|
||||
})
|
||||
if (!sprint) {
|
||||
return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ export async function GET(request: NextRequest) {
|
|||
async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> {
|
||||
const { prisma } = await import('@/lib/prisma')
|
||||
return prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
where: { product_id: productId, status: 'OPEN' },
|
||||
select: { id: true },
|
||||
orderBy: { created_at: 'desc' },
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue