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
|
|
@ -77,7 +77,7 @@ const mockPrisma = prisma as unknown as Mocked
|
|||
|
||||
const SPRINT_OK = {
|
||||
id: 'sprint-1',
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
product_id: 'prod-1',
|
||||
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
||||
}
|
||||
|
|
@ -303,7 +303,7 @@ describe('startSprintRunAction — SPRINT_BATCH', () => {
|
|||
|
||||
describe('startSprintRunAction — guards', () => {
|
||||
it('weigert wanneer Sprint niet ACTIVE is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'COMPLETED' })
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
|
||||
|
|
@ -346,7 +346,7 @@ describe('resumeSprintAction', () => {
|
|||
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
data: { status: 'OPEN', completed_at: null },
|
||||
})
|
||||
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
||||
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
||||
|
|
@ -359,7 +359,7 @@ describe('resumeSprintAction', () => {
|
|||
})
|
||||
|
||||
it('weigert als sprint niet FAILED is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'ACTIVE' })
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' })
|
||||
|
||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as {
|
|||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn
|
|||
const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null }
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
|
|||
}
|
||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
|
||||
const STORY = {
|
||||
id: 'story-1',
|
||||
title: 'Account aanmaken',
|
||||
|
|
|
|||
|
|
@ -197,7 +197,7 @@ describe('GET /api/products/:id/next-story', () => {
|
|||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
product_id: 'prod-other',
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
product: expect.objectContaining({
|
||||
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
|
|||
}
|
||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
||||
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' }
|
||||
const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' }
|
||||
|
||||
function makeTask(n: number) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ describe('propagateStatusUpwards — story-niveau', () => {
|
|||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
|
@ -171,7 +171,7 @@ describe('propagateStatusUpwards — story-niveau', () => {
|
|||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'COMPLETED' })
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'CLOSED' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'TO_DO')
|
||||
|
|
@ -243,7 +243,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
|||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
|
||||
// findMany on pbi:
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
|
|
@ -285,7 +285,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
|||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
||||
|
|
@ -295,7 +295,7 @@ describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
|||
expect(result.sprintRunChanged).toBe(true)
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'sprint-1' },
|
||||
data: expect.objectContaining({ status: 'COMPLETED' }),
|
||||
data: expect.objectContaining({ status: 'CLOSED' }),
|
||||
}))
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'run-1' },
|
||||
|
|
|
|||
42
actions/active-sprint.ts
Normal file
42
actions/active-sprint.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
const setSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
sprintId: z.string().min(1),
|
||||
})
|
||||
|
||||
export async function setActiveSprintAction(productId: string, sprintId: string) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = setSchema.safeParse({ productId, sprintId })
|
||||
if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' }
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: {
|
||||
id: parsed.data.sprintId,
|
||||
product_id: parsed.data.productId,
|
||||
product: productAccessFilter(session.userId),
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' }
|
||||
|
||||
await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId)
|
||||
revalidatePath('/', 'layout')
|
||||
return { success: true, sprintId: parsed.data.sprintId }
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ async function startSprintRunCore(
|
|||
include: { product: true },
|
||||
})
|
||||
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
|
||||
if (sprint.status !== 'ACTIVE')
|
||||
if (sprint.status !== 'OPEN')
|
||||
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
|
||||
|
||||
const activeRun = await tx.sprintRun.findFirst({
|
||||
|
|
@ -80,6 +80,9 @@ async function startSprintRunCore(
|
|||
include: {
|
||||
pbi: true,
|
||||
tasks: {
|
||||
// EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt
|
||||
// TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet
|
||||
// terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen.
|
||||
where: { status: 'TO_DO' },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
|
|
@ -246,7 +249,7 @@ export async function resumeSprintAction(input: unknown): Promise<StartResult> {
|
|||
// Sprint terug naar ACTIVE
|
||||
await tx.sprint.update({
|
||||
where: { id: sprint_id },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
data: { status: 'OPEN', completed_at: null },
|
||||
})
|
||||
|
||||
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||
import { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||
import { z } from 'zod'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -51,7 +53,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||
|
||||
const existing = await prisma.sprint.findFirst({
|
||||
where: { product_id: parsed.data.productId, status: 'ACTIVE' },
|
||||
where: { product_id: parsed.data.productId, status: 'OPEN' },
|
||||
})
|
||||
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
|||
product_id: parsed.data.productId,
|
||||
code,
|
||||
sprint_goal: parsed.data.sprint_goal,
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
start_date: parsed.data.start_date,
|
||||
end_date: parsed.data.end_date,
|
||||
},
|
||||
|
|
@ -271,7 +273,7 @@ export async function completeSprintAction(
|
|||
),
|
||||
prisma.sprint.update({
|
||||
where: { id: sprintId },
|
||||
data: { status: 'COMPLETED', completed_at: new Date() },
|
||||
data: { status: 'CLOSED', completed_at: new Date() },
|
||||
}),
|
||||
])
|
||||
|
||||
|
|
@ -307,3 +309,76 @@ export async function setAllSprintTasksDoneAction(
|
|||
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const createSprintWithPbisSchema = z.object({
|
||||
productId: z.string().min(1),
|
||||
sprint_goal: z.string().min(1).max(2000),
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
pbi_ids: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
function parseDate(value: string | null | undefined): Date | null {
|
||||
if (!value) return null
|
||||
const d = new Date(value)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
export async function createSprintWithPbisAction(input: {
|
||||
productId: string
|
||||
sprint_goal: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
pbi_ids: string[]
|
||||
}): Promise<{ success: true; sprintId: string } | { error: string; code: number }> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const limited = enforceUserRateLimit('create-sprint', session.userId)
|
||||
if (limited) return { error: limited.error, code: limited.code }
|
||||
|
||||
const parsed = createSprintWithPbisSchema.safeParse(input)
|
||||
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
|
||||
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||
|
||||
const pbis = await prisma.pbi.findMany({
|
||||
where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId },
|
||||
select: { id: true },
|
||||
})
|
||||
if (pbis.length !== parsed.data.pbi_ids.length) {
|
||||
return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 }
|
||||
}
|
||||
|
||||
const sprint = await createWithCodeRetry(
|
||||
() => generateNextSprintCode(parsed.data.productId),
|
||||
(code) =>
|
||||
prisma.$transaction(async (tx) => {
|
||||
const created = await tx.sprint.create({
|
||||
data: {
|
||||
product_id: parsed.data.productId,
|
||||
code,
|
||||
sprint_goal: parsed.data.sprint_goal,
|
||||
status: 'OPEN',
|
||||
start_date: parseDate(parsed.data.start_date),
|
||||
end_date: parseDate(parsed.data.end_date),
|
||||
},
|
||||
})
|
||||
await tx.story.updateMany({
|
||||
where: { pbi_id: { in: parsed.data.pbi_ids } },
|
||||
data: { sprint_id: created.id, status: 'IN_SPRINT' },
|
||||
})
|
||||
await tx.task.updateMany({
|
||||
where: { story: { pbi_id: { in: parsed.data.pbi_ids } } },
|
||||
data: { sprint_id: created.id },
|
||||
})
|
||||
return created
|
||||
}),
|
||||
)
|
||||
|
||||
await setActiveSprintCookie(parsed.data.productId, sprint.id)
|
||||
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
|
||||
return { success: true, sprintId: sprint.id }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -349,7 +349,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string)
|
|||
const userId = session.userId
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
where: { product_id: productId, status: 'OPEN' },
|
||||
})
|
||||
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { toast } from 'sonner'
|
||||
import { CheckSquare, Square } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
|
@ -33,6 +34,7 @@ import { cn } from '@/lib/utils'
|
|||
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||
import { BacklogCard } from './backlog-card'
|
||||
import { EmptyPanel } from './empty-panel'
|
||||
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||
|
|
@ -124,19 +126,26 @@ function SortablePbiRow({
|
|||
pbi,
|
||||
isSelected,
|
||||
isDemo,
|
||||
selectionMode,
|
||||
isChecked,
|
||||
onSelect,
|
||||
onToggleCheck,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
pbi: Pbi
|
||||
isSelected: boolean
|
||||
isDemo: boolean
|
||||
selectionMode: boolean
|
||||
isChecked: boolean
|
||||
onSelect: () => void
|
||||
onToggleCheck: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: pbi.id,
|
||||
disabled: selectionMode,
|
||||
})
|
||||
|
||||
const style = {
|
||||
|
|
@ -144,6 +153,37 @@ function SortablePbiRow({
|
|||
transition,
|
||||
}
|
||||
|
||||
if (selectionMode) {
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
title={pbi.title}
|
||||
code={pbi.code}
|
||||
priority={pbi.priority}
|
||||
isSelected={isChecked}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isChecked}
|
||||
onClick={onToggleCheck}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
|
||||
badge={
|
||||
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
|
||||
{PBI_STATUS_LABELS[pbi.status]}
|
||||
</Badge>
|
||||
}
|
||||
actions={
|
||||
<div
|
||||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BacklogCard
|
||||
ref={setNodeRef}
|
||||
|
|
@ -207,8 +247,25 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectionMode, setSelectionMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [newSprintOpen, setNewSprintOpen] = useState(false)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
function exitSelection() {
|
||||
setSelectionMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
function toggleCheck(id: string) {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Load persisted preferences once after mount (client-only).
|
||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
||||
useEffect(() => {
|
||||
|
|
@ -452,8 +509,23 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectionMode ? 'default' : 'outline'}
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo}
|
||||
onClick={() => {
|
||||
if (isDemo) return
|
||||
if (selectionMode) exitSelection()
|
||||
else setSelectionMode(true)
|
||||
}}
|
||||
>
|
||||
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
|
||||
</Button>
|
||||
</DemoTooltip>
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={isDemo || selectionMode}
|
||||
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
|
||||
>
|
||||
+ PBI
|
||||
|
|
@ -486,7 +558,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
pbi={pbi}
|
||||
isSelected={selectedPbiId === pbi.id}
|
||||
isDemo={isDemo}
|
||||
selectionMode={selectionMode}
|
||||
isChecked={selectedIds.has(pbi.id)}
|
||||
onSelect={() => selectPbi(pbi.id)}
|
||||
onToggleCheck={() => toggleCheck(pbi.id)}
|
||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||
onDelete={() => handleDelete(pbi.id)}
|
||||
/>
|
||||
|
|
@ -507,11 +582,53 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{selectionMode && (
|
||||
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
|
||||
<span className="text-sm text-foreground">
|
||||
{selectedIds.size} geselecteerd
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs"
|
||||
onClick={exitSelection}
|
||||
>
|
||||
Annuleer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={selectedIds.size === 0}
|
||||
onClick={() => setNewSprintOpen(true)}
|
||||
>
|
||||
Nieuwe sprint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PbiDialog
|
||||
state={dialogState}
|
||||
onClose={() => setDialogState(null)}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
|
||||
<NewSprintDialog
|
||||
open={newSprintOpen}
|
||||
productId={productId}
|
||||
pbiIds={Array.from(selectedIds)}
|
||||
onOpenChange={(open) => {
|
||||
setNewSprintOpen(open)
|
||||
if (!open) {
|
||||
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
|
||||
}
|
||||
}}
|
||||
onCreated={() => {
|
||||
setNewSprintOpen(false)
|
||||
exitSelection()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ import { NotificationsBell } from '@/components/shared/notifications-bell'
|
|||
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setActiveProductAction } from '@/actions/active-product'
|
||||
import { setActiveSprintAction } from '@/actions/active-sprint'
|
||||
import type { SprintStatusApi } from '@/lib/task-status'
|
||||
|
||||
type SprintItem = { id: string; code: string; status: SprintStatusApi }
|
||||
|
||||
interface NavBarProps {
|
||||
isDemo: boolean
|
||||
|
|
@ -29,10 +33,26 @@ interface NavBarProps {
|
|||
email: string | null
|
||||
activeProduct: { id: string; name: string } | null
|
||||
products: { id: string; name: string }[]
|
||||
hasActiveSprint: boolean
|
||||
sprints: SprintItem[]
|
||||
activeSprint: SprintItem | null
|
||||
buildingSprintIds: string[]
|
||||
minQuotaPct: number
|
||||
}
|
||||
|
||||
const SPRINT_STATUS_LABEL: Record<SprintStatusApi, string> = {
|
||||
open: 'Open',
|
||||
closed: 'Gesloten',
|
||||
archived: 'Gearchiveerd',
|
||||
failed: 'Mislukt',
|
||||
}
|
||||
|
||||
const SPRINT_STATUS_BADGE: Record<SprintStatusApi, string> = {
|
||||
open: 'bg-status-in-progress text-foreground',
|
||||
closed: 'bg-status-done text-foreground',
|
||||
archived: 'bg-surface-container text-muted-foreground',
|
||||
failed: 'bg-status-failed text-foreground',
|
||||
}
|
||||
|
||||
export function NavBar({
|
||||
isDemo,
|
||||
roles,
|
||||
|
|
@ -41,12 +61,16 @@ export function NavBar({
|
|||
email,
|
||||
activeProduct,
|
||||
products,
|
||||
hasActiveSprint,
|
||||
sprints,
|
||||
activeSprint,
|
||||
buildingSprintIds,
|
||||
minQuotaPct,
|
||||
}: NavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const buildingSet = new Set(buildingSprintIds)
|
||||
const hasActiveSprint = !!activeSprint
|
||||
|
||||
function handleSwitchProduct(productId: string) {
|
||||
startTransition(async () => {
|
||||
|
|
@ -61,6 +85,23 @@ export function NavBar({
|
|||
})
|
||||
}
|
||||
|
||||
function handleSwitchSprint(sprintId: string) {
|
||||
if (!activeProduct) return
|
||||
if (sprintId === activeSprint?.id) return
|
||||
startTransition(async () => {
|
||||
const result = await setActiveSprintAction(activeProduct.id, sprintId)
|
||||
if (result?.error) {
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
||||
return
|
||||
}
|
||||
if (pathname.includes('/sprint')) {
|
||||
router.push(`/products/${activeProduct.id}/sprint/${sprintId}`)
|
||||
} else {
|
||||
router.refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const activeId = activeProduct?.id ?? null
|
||||
|
||||
// Nav link helpers
|
||||
|
|
@ -90,7 +131,6 @@ export function NavBar({
|
|||
|
||||
const sprintNode = () => {
|
||||
if (!activeId) return disabledSpan('Sprint')
|
||||
const href = `/products/${activeId}/sprint`
|
||||
const isActive = pathname.includes('/sprint')
|
||||
if (!hasActiveSprint) {
|
||||
return (
|
||||
|
|
@ -107,6 +147,7 @@ export function NavBar({
|
|||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const href = `/products/${activeId}/sprint/${activeSprint!.id}`
|
||||
return navLink(href, 'Sprint', isActive)
|
||||
}
|
||||
|
||||
|
|
@ -149,8 +190,8 @@ export function NavBar({
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Midden: actief product dropdown */}
|
||||
<div className="flex items-center justify-center">
|
||||
{/* Midden: actief product + sprint, gestapeld */}
|
||||
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||
{activeProduct ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
|
|
@ -187,6 +228,70 @@ export function NavBar({
|
|||
) : (
|
||||
<span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span>
|
||||
)}
|
||||
|
||||
{activeProduct && (
|
||||
sprints.length === 0 ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="text-xs text-muted-foreground/50 px-2 cursor-not-allowed select-none"
|
||||
aria-disabled="true"
|
||||
>
|
||||
Geen sprints
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none"
|
||||
>
|
||||
<span className="truncate max-w-[160px]">
|
||||
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
||||
</span>
|
||||
{activeSprint && (
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0',
|
||||
buildingSet.has(activeSprint.id)
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: SPRINT_STATUS_BADGE[activeSprint.status],
|
||||
)}
|
||||
>
|
||||
{buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" className="w-64">
|
||||
{sprints.map(s => (
|
||||
<DropdownMenuItem
|
||||
key={s.id}
|
||||
onClick={() => handleSwitchSprint(s.id)}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2',
|
||||
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{s.code}</span>
|
||||
<Badge
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0 shrink-0',
|
||||
buildingSet.has(s.id)
|
||||
? 'bg-warning text-warning-foreground'
|
||||
: SPRINT_STATUS_BADGE[s.status],
|
||||
)}
|
||||
>
|
||||
{buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
|
||||
</Badge>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rechts: solo-status + notifications + account-menu */}
|
||||
|
|
|
|||
187
components/sprint/new-sprint-dialog.tsx
Normal file
187
components/sprint/new-sprint-dialog.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
useDirtyCloseGuard,
|
||||
DirtyCloseGuardDialog,
|
||||
} from '@/components/shared/use-dirty-close-guard'
|
||||
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
||||
import {
|
||||
entityDialogContentClasses,
|
||||
entityDialogFooterClasses,
|
||||
entityDialogHeaderClasses,
|
||||
} from '@/components/shared/entity-dialog-layout'
|
||||
import { createSprintWithPbisAction } from '@/actions/sprints'
|
||||
|
||||
interface NewSprintDialogProps {
|
||||
open: boolean
|
||||
productId: string
|
||||
pbiIds: string[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCreated?: (sprintId: string) => void
|
||||
}
|
||||
|
||||
function todayLocalDate() {
|
||||
return new Date().toLocaleDateString('en-CA')
|
||||
}
|
||||
|
||||
export function NewSprintDialog({
|
||||
open,
|
||||
productId,
|
||||
pbiIds,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: NewSprintDialogProps) {
|
||||
const [sprintGoal, setSprintGoal] = useState('')
|
||||
const [startDate, setStartDate] = useState(todayLocalDate())
|
||||
const [endDate, setEndDate] = useState(todayLocalDate())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
function reset() {
|
||||
setSprintGoal('')
|
||||
setStartDate(todayLocalDate())
|
||||
setEndDate(todayLocalDate())
|
||||
setError(null)
|
||||
setDirty(false)
|
||||
}
|
||||
|
||||
const closeGuard = useDirtyCloseGuard(dirty, () => {
|
||||
onOpenChange(false)
|
||||
reset()
|
||||
})
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!sprintGoal.trim() || pbiIds.length === 0) return
|
||||
setError(null)
|
||||
startTransition(async () => {
|
||||
const result = await createSprintWithPbisAction({
|
||||
productId,
|
||||
sprint_goal: sprintGoal.trim(),
|
||||
start_date: startDate || null,
|
||||
end_date: endDate || null,
|
||||
pbi_ids: pbiIds,
|
||||
})
|
||||
if ('error' in result) {
|
||||
setError(result.error)
|
||||
toast.error(result.error)
|
||||
return
|
||||
}
|
||||
toast.success('Nieuwe sprint aangemaakt')
|
||||
reset()
|
||||
onCreated?.(result.sprintId)
|
||||
router.push(`/products/${productId}/sprint/${result.sprintId}`)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closeGuard.attemptClose()
|
||||
else onOpenChange(o)
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={entityDialogContentClasses}
|
||||
>
|
||||
<div className={entityDialogHeaderClasses}>
|
||||
<DialogTitle className="text-xl font-semibold">Nieuwe sprint</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
ref={formRef}
|
||||
id="new-sprint-form"
|
||||
onSubmit={handleSubmit}
|
||||
onChange={() => setDirty(true)}
|
||||
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Sprint Goal <span className="text-error">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={sprintGoal}
|
||||
onChange={(e) => setSprintGoal(e.target.value)}
|
||||
required
|
||||
rows={3}
|
||||
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Startdatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className={entityDialogFooterClasses}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={closeGuard.attemptClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="new-sprint-form"
|
||||
disabled={isPending || !sprintGoal.trim() || pbiIds.length === 0}
|
||||
>
|
||||
{isPending ? 'Aanmaken…' : 'Sprint aanmaken'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DirtyCloseGuardDialog guard={closeGuard} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from '@/components/ui/dialog'
|
||||
import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context'
|
||||
|
||||
type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED'
|
||||
type SprintStatusValue = 'OPEN' | 'CLOSED' | 'ARCHIVED' | 'FAILED'
|
||||
type SprintRunStatusValue =
|
||||
| 'QUEUED'
|
||||
| 'RUNNING'
|
||||
|
|
@ -78,7 +78,7 @@ export function SprintRunControls({
|
|||
activeSprintRunStatus === 'RUNNING' ||
|
||||
activeSprintRunStatus === 'PAUSED')
|
||||
|
||||
const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun
|
||||
const canStart = sprintStatus === 'OPEN' && !hasActiveRun
|
||||
const canResume = sprintStatus === 'FAILED'
|
||||
const canResumePaused =
|
||||
activeSprintRunStatus === 'PAUSED' && pauseContext !== null
|
||||
|
|
|
|||
|
|
@ -27,13 +27,24 @@ const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
|
|||
TO_DO: 'IN_PROGRESS',
|
||||
IN_PROGRESS: 'DONE',
|
||||
DONE: 'TO_DO',
|
||||
EXCLUDED: 'TO_DO',
|
||||
}
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
||||
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||
EXCLUDED: 'bg-surface-container-low text-muted-foreground border-border',
|
||||
FAILED: 'bg-status-failed/15 text-status-failed border-status-failed/30',
|
||||
REVIEW: 'bg-status-review/15 text-status-review border-status-review/30',
|
||||
}
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
TO_DO: 'To Do',
|
||||
IN_PROGRESS: 'Bezig',
|
||||
REVIEW: 'Review',
|
||||
DONE: 'Klaar',
|
||||
FAILED: 'Mislukt',
|
||||
EXCLUDED: 'Uitgesloten',
|
||||
}
|
||||
const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' }
|
||||
|
||||
|
||||
export interface Task {
|
||||
|
|
@ -101,6 +112,7 @@ function SortableTaskRow({
|
|||
<p className={cn(
|
||||
'text-sm leading-snug line-clamp-2 flex-1',
|
||||
task.status === 'DONE' && 'line-through text-muted-foreground',
|
||||
task.status === 'EXCLUDED' && 'text-muted-foreground/70 italic',
|
||||
)}>
|
||||
{task.title}
|
||||
</p>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 908 KiB After Width: | Height: | Size: 937 KiB |
66
lib/active-sprint.ts
Normal file
66
lib/active-sprint.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import type { SprintStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type ActiveSprint = {
|
||||
id: string
|
||||
code: string
|
||||
status: SprintStatus
|
||||
}
|
||||
|
||||
function cookieName(productId: string): string {
|
||||
return `active_sprint_${productId}`
|
||||
}
|
||||
|
||||
export async function getActiveSprintIdFromCookie(
|
||||
productId: string,
|
||||
): Promise<string | null> {
|
||||
const store = await cookies()
|
||||
return store.get(cookieName(productId))?.value ?? null
|
||||
}
|
||||
|
||||
export async function setActiveSprintCookie(
|
||||
productId: string,
|
||||
sprintId: string,
|
||||
): Promise<void> {
|
||||
const store = await cookies()
|
||||
store.set(cookieName(productId), sprintId, {
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
})
|
||||
}
|
||||
|
||||
export async function clearActiveSprintCookie(productId: string): Promise<void> {
|
||||
const store = await cookies()
|
||||
store.delete(cookieName(productId))
|
||||
}
|
||||
|
||||
export async function resolveActiveSprint(
|
||||
productId: string,
|
||||
): Promise<ActiveSprint | null> {
|
||||
const cookieId = await getActiveSprintIdFromCookie(productId)
|
||||
|
||||
if (cookieId) {
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { id: cookieId, product_id: productId },
|
||||
select: { id: true, code: true, status: true },
|
||||
})
|
||||
if (sprint) return sprint
|
||||
}
|
||||
|
||||
const open = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'OPEN' },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, code: true, status: true },
|
||||
})
|
||||
if (open) return open
|
||||
|
||||
const closed = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'CLOSED' },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, code: true, status: true },
|
||||
})
|
||||
return closed ?? null
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
|
|||
|
||||
const sprints = await prisma.sprint.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
product: productAccessFilter(userId),
|
||||
},
|
||||
select: {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export async function getSprintStatusBreakdown(userId: string): Promise<StatusCo
|
|||
where: {
|
||||
story: {
|
||||
sprint: {
|
||||
status: 'ACTIVE',
|
||||
status: 'OPEN',
|
||||
product: productAccessFilter(userId),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export interface VelocityData {
|
|||
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
|
||||
const sprints = await prisma.sprint.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
status: 'CLOSED',
|
||||
product: productAccessFilter(userId),
|
||||
},
|
||||
orderBy: { completed_at: 'desc' },
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export async function getAlignmentTrend(
|
|||
): Promise<TrendPoint[]> {
|
||||
const sprints = await prisma.sprint.findMany({
|
||||
where: {
|
||||
status: 'COMPLETED',
|
||||
status: 'CLOSED',
|
||||
product: productAccessFilter(userId),
|
||||
},
|
||||
orderBy: { completed_at: 'desc' },
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const TASK_DB_TO_API = {
|
|||
REVIEW: 'review',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
EXCLUDED: 'excluded',
|
||||
} as const satisfies Record<TaskStatus, string>
|
||||
|
||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||
|
|
@ -23,6 +24,7 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
|||
review: 'REVIEW',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
excluded: 'EXCLUDED',
|
||||
}
|
||||
|
||||
const STORY_DB_TO_API = {
|
||||
|
|
@ -54,14 +56,16 @@ const PBI_API_TO_DB: Record<string, PbiStatus> = {
|
|||
}
|
||||
|
||||
const SPRINT_DB_TO_API = {
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
OPEN: 'open',
|
||||
CLOSED: 'closed',
|
||||
ARCHIVED: 'archived',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<SprintStatus, string>
|
||||
|
||||
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
|
||||
active: 'ACTIVE',
|
||||
completed: 'COMPLETED',
|
||||
open: 'OPEN',
|
||||
closed: 'CLOSED',
|
||||
archived: 'ARCHIVED',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,15 +131,15 @@ export async function propagateStatusUpwards(
|
|||
|
||||
let nextStatus: SprintStatus
|
||||
if (anyPbiFailed) nextStatus = 'FAILED'
|
||||
else if (allPbisDone) nextStatus = 'COMPLETED'
|
||||
else nextStatus = 'ACTIVE'
|
||||
else if (allPbisDone) nextStatus = 'CLOSED'
|
||||
else nextStatus = 'OPEN'
|
||||
|
||||
if (nextStatus !== sprint.status) {
|
||||
await tx.sprint.update({
|
||||
where: { id: sprint.id },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}),
|
||||
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
|
||||
},
|
||||
})
|
||||
sprintChanged = true
|
||||
|
|
@ -149,7 +149,7 @@ export async function propagateStatusUpwards(
|
|||
|
||||
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
|
||||
let sprintRunChanged = false
|
||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') {
|
||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
|
||||
const job = await tx.claudeJob.findFirst({
|
||||
where: { task_id: taskId, sprint_run_id: { not: null } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "scrum4me",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "npx --yes kill-port 3000 || exit 0",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
-- PBI-63: Sprint-lifecycle migratie
|
||||
--
|
||||
-- 1. Hernoem SprintStatus ACTIVE → OPEN, COMPLETED → CLOSED (bestaande data behouden, geen rewrite)
|
||||
-- 2. Voeg ARCHIVED toe (FAILED blijft)
|
||||
-- 3. Pas Sprint.status default aan naar OPEN
|
||||
-- 4. Voeg EXCLUDED toe aan TaskStatus
|
||||
--
|
||||
-- ALTER TYPE ... RENAME VALUE werkt vanaf PostgreSQL 10 zonder data-rewrite.
|
||||
-- ALTER TYPE ... ADD VALUE moet buiten een transaction-block uitgevoerd worden;
|
||||
-- Prisma migrate runt elk SQL-bestand zonder impliciete BEGIN/COMMIT, dus
|
||||
-- losse statements zijn voldoende.
|
||||
|
||||
ALTER TYPE "SprintStatus" RENAME VALUE 'ACTIVE' TO 'OPEN';
|
||||
ALTER TYPE "SprintStatus" RENAME VALUE 'COMPLETED' TO 'CLOSED';
|
||||
ALTER TYPE "SprintStatus" ADD VALUE 'ARCHIVED';
|
||||
|
||||
ALTER TABLE "sprints" ALTER COLUMN "status" SET DEFAULT 'OPEN';
|
||||
|
||||
ALTER TYPE "TaskStatus" ADD VALUE 'EXCLUDED';
|
||||
|
|
@ -61,6 +61,7 @@ enum TaskStatus {
|
|||
REVIEW
|
||||
DONE
|
||||
FAILED
|
||||
EXCLUDED
|
||||
}
|
||||
|
||||
enum LogType {
|
||||
|
|
@ -75,8 +76,9 @@ enum TestStatus {
|
|||
}
|
||||
|
||||
enum SprintStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
OPEN
|
||||
CLOSED
|
||||
ARCHIVED
|
||||
FAILED
|
||||
}
|
||||
|
||||
|
|
@ -302,7 +304,7 @@ model Sprint {
|
|||
product_id String
|
||||
code String @db.VarChar(30)
|
||||
sprint_goal String
|
||||
status SprintStatus @default(ACTIVE)
|
||||
status SprintStatus @default(OPEN)
|
||||
start_date DateTime? @db.Date
|
||||
end_date DateTime? @db.Date
|
||||
created_at DateTime @default(now())
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export type ParsedMilestone = {
|
|||
title: string
|
||||
goal: string
|
||||
priority: 1 | 2 | 3 | 4
|
||||
sprint_status: 'ACTIVE' | 'COMPLETED'
|
||||
sprint_status: 'OPEN' | 'CLOSED'
|
||||
sort_order: number
|
||||
stories: ParsedStory[]
|
||||
}
|
||||
|
|
@ -66,19 +66,19 @@ const MILESTONE_GOAL: Record<string, string> = {
|
|||
}
|
||||
|
||||
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
|
||||
M0: 'COMPLETED',
|
||||
M1: 'COMPLETED',
|
||||
M2: 'COMPLETED',
|
||||
M3: 'COMPLETED',
|
||||
'M3.5': 'COMPLETED',
|
||||
M4: 'COMPLETED',
|
||||
M5: 'COMPLETED',
|
||||
M6: 'COMPLETED',
|
||||
M7: 'COMPLETED',
|
||||
M8: 'COMPLETED',
|
||||
M9: 'COMPLETED',
|
||||
M10: 'COMPLETED',
|
||||
M11: 'COMPLETED',
|
||||
M0: 'CLOSED',
|
||||
M1: 'CLOSED',
|
||||
M2: 'CLOSED',
|
||||
M3: 'CLOSED',
|
||||
'M3.5': 'CLOSED',
|
||||
M4: 'CLOSED',
|
||||
M5: 'CLOSED',
|
||||
M6: 'CLOSED',
|
||||
M7: 'CLOSED',
|
||||
M8: 'CLOSED',
|
||||
M9: 'CLOSED',
|
||||
M10: 'CLOSED',
|
||||
M11: 'CLOSED',
|
||||
}
|
||||
|
||||
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
||||
|
|
@ -154,7 +154,7 @@ export async function loadBacklog(
|
|||
title,
|
||||
goal: MILESTONE_GOAL[key] ?? title,
|
||||
priority: MILESTONE_PRIORITY[key] ?? 4,
|
||||
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED',
|
||||
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'CLOSED',
|
||||
sort_order: milestones.length + 1,
|
||||
stories: [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ async function main() {
|
|||
const forceOpen = ms.key === 'M3.5'
|
||||
|
||||
for (const s of ms.stories) {
|
||||
const isActive = ms.sprint_status === 'ACTIVE'
|
||||
const isActive = ms.sprint_status === 'OPEN'
|
||||
const effectivelyDone = !forceOpen && s.status === 'DONE'
|
||||
const inSprint = isActive || effectivelyDone
|
||||
const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue