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 = {
|
const SPRINT_OK = {
|
||||||
id: 'sprint-1',
|
id: 'sprint-1',
|
||||||
status: 'ACTIVE',
|
status: 'OPEN',
|
||||||
product_id: 'prod-1',
|
product_id: 'prod-1',
|
||||||
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +303,7 @@ describe('startSprintRunAction — SPRINT_BATCH', () => {
|
||||||
|
|
||||||
describe('startSprintRunAction — guards', () => {
|
describe('startSprintRunAction — guards', () => {
|
||||||
it('weigert wanneer Sprint niet ACTIVE is', async () => {
|
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' })
|
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
|
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(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||||
where: { id: 'sprint-1' },
|
where: { id: 'sprint-1' },
|
||||||
data: { status: 'ACTIVE', completed_at: null },
|
data: { status: 'OPEN', completed_at: null },
|
||||||
})
|
})
|
||||||
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
||||||
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
||||||
|
|
@ -359,7 +359,7 @@ describe('resumeSprintAction', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('weigert als sprint niet FAILED is', async () => {
|
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' })
|
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
$transaction: ReturnType<typeof vi.fn>
|
$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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn
|
||||||
const mockGetIronSession = getIronSession 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 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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as {
|
||||||
}
|
}
|
||||||
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
|
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 = {
|
const STORY = {
|
||||||
id: 'story-1',
|
id: 'story-1',
|
||||||
title: 'Account aanmaken',
|
title: 'Account aanmaken',
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ describe('GET /api/products/:id/next-story', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: expect.objectContaining({
|
where: expect.objectContaining({
|
||||||
product_id: 'prod-other',
|
product_id: 'prod-other',
|
||||||
status: 'ACTIVE',
|
status: 'OPEN',
|
||||||
product: expect.objectContaining({
|
product: expect.objectContaining({
|
||||||
OR: expect.arrayContaining([{ user_id: 'user-1' }]),
|
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 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) {
|
function makeTask(n: number) {
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@ describe('propagateStatusUpwards — story-niveau', () => {
|
||||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||||
return []
|
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' }])
|
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
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' }]
|
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||||
return []
|
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' }])
|
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||||
|
|
||||||
const result = await propagateStatusUpwards('task-1', 'TO_DO')
|
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' }]
|
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' })
|
||||||
// findMany on pbi:
|
// findMany on pbi:
|
||||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
|
;(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' })
|
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' }]
|
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||||
return []
|
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.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.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
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(result.sprintRunChanged).toBe(true)
|
||||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
where: { id: 'sprint-1' },
|
where: { id: 'sprint-1' },
|
||||||
data: expect.objectContaining({ status: 'COMPLETED' }),
|
data: expect.objectContaining({ status: 'CLOSED' }),
|
||||||
}))
|
}))
|
||||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
where: { id: 'run-1' },
|
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 },
|
include: { product: true },
|
||||||
})
|
})
|
||||||
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
|
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 }
|
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
|
||||||
|
|
||||||
const activeRun = await tx.sprintRun.findFirst({
|
const activeRun = await tx.sprintRun.findFirst({
|
||||||
|
|
@ -80,6 +80,9 @@ async function startSprintRunCore(
|
||||||
include: {
|
include: {
|
||||||
pbi: true,
|
pbi: true,
|
||||||
tasks: {
|
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' },
|
where: { status: 'TO_DO' },
|
||||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||||
},
|
},
|
||||||
|
|
@ -246,7 +249,7 @@ export async function resumeSprintAction(input: unknown): Promise<StartResult> {
|
||||||
// Sprint terug naar ACTIVE
|
// Sprint terug naar ACTIVE
|
||||||
await tx.sprint.update({
|
await tx.sprint.update({
|
||||||
where: { id: sprint_id },
|
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)
|
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||||
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||||
|
import { setActiveSprintCookie } from '@/lib/active-sprint'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
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 }
|
if (!product) return { error: 'Product niet gevonden', code: 403 }
|
||||||
|
|
||||||
const existing = await prisma.sprint.findFirst({
|
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 }
|
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,
|
product_id: parsed.data.productId,
|
||||||
code,
|
code,
|
||||||
sprint_goal: parsed.data.sprint_goal,
|
sprint_goal: parsed.data.sprint_goal,
|
||||||
status: 'ACTIVE',
|
status: 'OPEN',
|
||||||
start_date: parsed.data.start_date,
|
start_date: parsed.data.start_date,
|
||||||
end_date: parsed.data.end_date,
|
end_date: parsed.data.end_date,
|
||||||
},
|
},
|
||||||
|
|
@ -271,7 +273,7 @@ export async function completeSprintAction(
|
||||||
),
|
),
|
||||||
prisma.sprint.update({
|
prisma.sprint.update({
|
||||||
where: { id: sprintId },
|
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`)
|
revalidatePath(`/products/${sprint.product_id}/sprint`)
|
||||||
return { ok: true }
|
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 userId = session.userId
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
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' }
|
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
||||||
getBurndownData(userId),
|
getBurndownData(userId),
|
||||||
getSprintStatusBreakdown(userId),
|
getSprintStatusBreakdown(userId),
|
||||||
prisma.sprint.findMany({
|
prisma.sprint.findMany({
|
||||||
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
where: { status: 'OPEN', product: productAccessFilter(userId) },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { redirect } from 'next/navigation'
|
||||||
import { requireSession } from '@/lib/auth-guard'
|
import { requireSession } from '@/lib/auth-guard'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
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 { NavBar } from '@/components/shared/nav-bar'
|
||||||
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
import { MinWidthBanner } from '@/components/shared/min-width-banner'
|
||||||
import { StatusBar } from '@/components/shared/status-bar'
|
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
|
// Resolve active product — clear stale reference if archived or inaccessible
|
||||||
let activeProduct: { id: string; name: string } | null = null
|
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) {
|
if (user.active_product_id) {
|
||||||
const product = await prisma.product.findFirst({
|
const product = await prisma.product.findFirst({
|
||||||
where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) },
|
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) {
|
if (product) {
|
||||||
activeProduct = product
|
activeProduct = product
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const allSprints = await prisma.sprint.findMany({
|
||||||
where: { product_id: product.id, status: 'ACTIVE' },
|
where: { product_id: product.id },
|
||||||
select: { id: true },
|
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 {
|
} else {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: session.userId },
|
where: { id: session.userId },
|
||||||
|
|
@ -71,7 +94,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
email={user.email}
|
email={user.email}
|
||||||
activeProduct={activeProduct}
|
activeProduct={activeProduct}
|
||||||
products={accessibleProducts}
|
products={accessibleProducts}
|
||||||
hasActiveSprint={hasActiveSprint}
|
sprints={sprints}
|
||||||
|
activeSprint={activeSprint}
|
||||||
|
buildingSprintIds={buildingSprintIds}
|
||||||
minQuotaPct={user.min_quota_pct}
|
minQuotaPct={user.min_quota_pct}
|
||||||
/>
|
/>
|
||||||
<MinWidthBanner />
|
<MinWidthBanner />
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
||||||
if (!product) notFound()
|
if (!product) notFound()
|
||||||
|
|
||||||
const [activeSprint, user] = await Promise.all([
|
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 } }),
|
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()
|
if (!product) notFound()
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const sprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
where: { product_id: id, status: 'OPEN' },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!sprint) {
|
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'
|
import { redirect } from 'next/navigation'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string; sprintId: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function SprintPlanningRedirect({ params }: Props) {
|
export default async function SprintPlanningRedirect({ params }: Props) {
|
||||||
const { id } = await params
|
const { id, sprintId } = await params
|
||||||
redirect(`/products/${id}/sprint`)
|
redirect(`/products/${id}/sprint/${sprintId}`)
|
||||||
}
|
}
|
||||||
|
|
@ -1,220 +1,15 @@
|
||||||
import { Suspense } from 'react'
|
import { redirect } from 'next/navigation'
|
||||||
import { notFound, redirect } from 'next/navigation'
|
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||||
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'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>
|
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 { id } = await params
|
||||||
const { newTask, storyId: storyIdParam, editTask } = await searchParams
|
const active = await resolveActiveSprint(id)
|
||||||
|
if (!active) {
|
||||||
const session = await getSession()
|
redirect(`/products/${id}?alert=no_sprint`)
|
||||||
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,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
redirect(`/products/${id}/sprint/${active.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`
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
||||||
if (!product) notFound()
|
if (!product) notFound()
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
const sprint = await prisma.sprint.findFirst({
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
where: { product_id: id, status: 'OPEN' },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!sprint) {
|
if (!sprint) {
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
|
||||||
REVIEW: { label: 'Review', dot: 'bg-status-review' },
|
REVIEW: { label: 'Review', dot: 'bg-status-review' },
|
||||||
DONE: { label: 'Klaar', dot: 'bg-status-done' },
|
DONE: { label: 'Klaar', dot: 'bg-status-done' },
|
||||||
FAILED: { label: 'Gefaald', dot: 'bg-status-failed' },
|
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.
|
// 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 }) {
|
function StatusIndicator({ status }: { status: TaskStatus }) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export async function GET(
|
||||||
|
|
||||||
const [activeSprint, openIdeas] = await Promise.all([
|
const [activeSprint, openIdeas] = await Promise.all([
|
||||||
prisma.sprint.findFirst({
|
prisma.sprint.findFirst({
|
||||||
where: { product_id: id, status: 'ACTIVE' },
|
where: { product_id: id, status: 'OPEN' },
|
||||||
select: { id: true, sprint_goal: true, status: true },
|
select: { id: true, sprint_goal: true, status: true },
|
||||||
}),
|
}),
|
||||||
prisma.idea.findMany({
|
prisma.idea.findMany({
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export async function GET(
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const sprint = await prisma.sprint.findFirst({
|
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) {
|
if (!sprint) {
|
||||||
return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 })
|
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> {
|
async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> {
|
||||||
const { prisma } = await import('@/lib/prisma')
|
const { prisma } = await import('@/lib/prisma')
|
||||||
return prisma.sprint.findFirst({
|
return prisma.sprint.findFirst({
|
||||||
where: { product_id: productId, status: 'ACTIVE' },
|
where: { product_id: productId, status: 'OPEN' },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { CheckSquare, Square } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
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 { PbiDialog, type PbiDialogState } from './pbi-dialog'
|
||||||
import { BacklogCard } from './backlog-card'
|
import { BacklogCard } from './backlog-card'
|
||||||
import { EmptyPanel } from './empty-panel'
|
import { EmptyPanel } from './empty-panel'
|
||||||
|
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
|
||||||
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
|
||||||
|
|
@ -124,19 +126,26 @@ function SortablePbiRow({
|
||||||
pbi,
|
pbi,
|
||||||
isSelected,
|
isSelected,
|
||||||
isDemo,
|
isDemo,
|
||||||
|
selectionMode,
|
||||||
|
isChecked,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onToggleCheck,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
pbi: Pbi
|
pbi: Pbi
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
selectionMode: boolean
|
||||||
|
isChecked: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
|
onToggleCheck: () => void
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: pbi.id,
|
id: pbi.id,
|
||||||
|
disabled: selectionMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
|
|
@ -144,6 +153,37 @@ function SortablePbiRow({
|
||||||
transition,
|
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 (
|
return (
|
||||||
<BacklogCard
|
<BacklogCard
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
|
|
@ -207,8 +247,25 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||||
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
|
||||||
const [activeDragId, setActiveDragId] = useState<string | 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()
|
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).
|
// Load persisted preferences once after mount (client-only).
|
||||||
// setState calls here are intentional: hydrating from localStorage on first paint.
|
// setState calls here are intentional: hydrating from localStorage on first paint.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -452,8 +509,23 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
<DemoTooltip show={isDemo}>
|
<DemoTooltip show={isDemo}>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
variant={selectionMode ? 'default' : 'outline'}
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
disabled={isDemo}
|
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 })}
|
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
|
||||||
>
|
>
|
||||||
+ PBI
|
+ PBI
|
||||||
|
|
@ -486,7 +558,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
pbi={pbi}
|
pbi={pbi}
|
||||||
isSelected={selectedPbiId === pbi.id}
|
isSelected={selectedPbiId === pbi.id}
|
||||||
isDemo={isDemo}
|
isDemo={isDemo}
|
||||||
|
selectionMode={selectionMode}
|
||||||
|
isChecked={selectedIds.has(pbi.id)}
|
||||||
onSelect={() => selectPbi(pbi.id)}
|
onSelect={() => selectPbi(pbi.id)}
|
||||||
|
onToggleCheck={() => toggleCheck(pbi.id)}
|
||||||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||||||
onDelete={() => handleDelete(pbi.id)}
|
onDelete={() => handleDelete(pbi.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -507,11 +582,53 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<PbiDialog
|
||||||
state={dialogState}
|
state={dialogState}
|
||||||
onClose={() => setDialogState(null)}
|
onClose={() => setDialogState(null)}
|
||||||
isDemo={isDemo}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ import { NotificationsBell } from '@/components/shared/notifications-bell'
|
||||||
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
|
import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { setActiveProductAction } from '@/actions/active-product'
|
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 {
|
interface NavBarProps {
|
||||||
isDemo: boolean
|
isDemo: boolean
|
||||||
|
|
@ -29,10 +33,26 @@ interface NavBarProps {
|
||||||
email: string | null
|
email: string | null
|
||||||
activeProduct: { id: string; name: string } | null
|
activeProduct: { id: string; name: string } | null
|
||||||
products: { id: string; name: string }[]
|
products: { id: string; name: string }[]
|
||||||
hasActiveSprint: boolean
|
sprints: SprintItem[]
|
||||||
|
activeSprint: SprintItem | null
|
||||||
|
buildingSprintIds: string[]
|
||||||
minQuotaPct: number
|
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({
|
export function NavBar({
|
||||||
isDemo,
|
isDemo,
|
||||||
roles,
|
roles,
|
||||||
|
|
@ -41,12 +61,16 @@ export function NavBar({
|
||||||
email,
|
email,
|
||||||
activeProduct,
|
activeProduct,
|
||||||
products,
|
products,
|
||||||
hasActiveSprint,
|
sprints,
|
||||||
|
activeSprint,
|
||||||
|
buildingSprintIds,
|
||||||
minQuotaPct,
|
minQuotaPct,
|
||||||
}: NavBarProps) {
|
}: NavBarProps) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
const buildingSet = new Set(buildingSprintIds)
|
||||||
|
const hasActiveSprint = !!activeSprint
|
||||||
|
|
||||||
function handleSwitchProduct(productId: string) {
|
function handleSwitchProduct(productId: string) {
|
||||||
startTransition(async () => {
|
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
|
const activeId = activeProduct?.id ?? null
|
||||||
|
|
||||||
// Nav link helpers
|
// Nav link helpers
|
||||||
|
|
@ -90,7 +131,6 @@ export function NavBar({
|
||||||
|
|
||||||
const sprintNode = () => {
|
const sprintNode = () => {
|
||||||
if (!activeId) return disabledSpan('Sprint')
|
if (!activeId) return disabledSpan('Sprint')
|
||||||
const href = `/products/${activeId}/sprint`
|
|
||||||
const isActive = pathname.includes('/sprint')
|
const isActive = pathname.includes('/sprint')
|
||||||
if (!hasActiveSprint) {
|
if (!hasActiveSprint) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,6 +147,7 @@ export function NavBar({
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const href = `/products/${activeId}/sprint/${activeSprint!.id}`
|
||||||
return navLink(href, 'Sprint', isActive)
|
return navLink(href, 'Sprint', isActive)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,8 +190,8 @@ export function NavBar({
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Midden: actief product dropdown */}
|
{/* Midden: actief product + sprint, gestapeld */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex flex-col items-center justify-center gap-0.5">
|
||||||
{activeProduct ? (
|
{activeProduct ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
|
|
@ -187,6 +228,70 @@ export function NavBar({
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-muted-foreground/50 select-none">Geen actief product</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Rechts: solo-status + notifications + account-menu */}
|
{/* 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'
|
} from '@/components/ui/dialog'
|
||||||
import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context'
|
import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context'
|
||||||
|
|
||||||
type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED'
|
type SprintStatusValue = 'OPEN' | 'CLOSED' | 'ARCHIVED' | 'FAILED'
|
||||||
type SprintRunStatusValue =
|
type SprintRunStatusValue =
|
||||||
| 'QUEUED'
|
| 'QUEUED'
|
||||||
| 'RUNNING'
|
| 'RUNNING'
|
||||||
|
|
@ -78,7 +78,7 @@ export function SprintRunControls({
|
||||||
activeSprintRunStatus === 'RUNNING' ||
|
activeSprintRunStatus === 'RUNNING' ||
|
||||||
activeSprintRunStatus === 'PAUSED')
|
activeSprintRunStatus === 'PAUSED')
|
||||||
|
|
||||||
const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun
|
const canStart = sprintStatus === 'OPEN' && !hasActiveRun
|
||||||
const canResume = sprintStatus === 'FAILED'
|
const canResume = sprintStatus === 'FAILED'
|
||||||
const canResumePaused =
|
const canResumePaused =
|
||||||
activeSprintRunStatus === 'PAUSED' && pauseContext !== null
|
activeSprintRunStatus === 'PAUSED' && pauseContext !== null
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,24 @@ const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
|
||||||
TO_DO: 'IN_PROGRESS',
|
TO_DO: 'IN_PROGRESS',
|
||||||
IN_PROGRESS: 'DONE',
|
IN_PROGRESS: 'DONE',
|
||||||
DONE: 'TO_DO',
|
DONE: 'TO_DO',
|
||||||
|
EXCLUDED: 'TO_DO',
|
||||||
}
|
}
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
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',
|
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',
|
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 {
|
export interface Task {
|
||||||
|
|
@ -101,6 +112,7 @@ function SortableTaskRow({
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'text-sm leading-snug line-clamp-2 flex-1',
|
'text-sm leading-snug line-clamp-2 flex-1',
|
||||||
task.status === 'DONE' && 'line-through text-muted-foreground',
|
task.status === 'DONE' && 'line-through text-muted-foreground',
|
||||||
|
task.status === 'EXCLUDED' && 'text-muted-foreground/70 italic',
|
||||||
)}>
|
)}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</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({
|
const sprints = await prisma.sprint.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: 'OPEN',
|
||||||
product: productAccessFilter(userId),
|
product: productAccessFilter(userId),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export async function getSprintStatusBreakdown(userId: string): Promise<StatusCo
|
||||||
where: {
|
where: {
|
||||||
story: {
|
story: {
|
||||||
sprint: {
|
sprint: {
|
||||||
status: 'ACTIVE',
|
status: 'OPEN',
|
||||||
product: productAccessFilter(userId),
|
product: productAccessFilter(userId),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export interface VelocityData {
|
||||||
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
|
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
|
||||||
const sprints = await prisma.sprint.findMany({
|
const sprints = await prisma.sprint.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'COMPLETED',
|
status: 'CLOSED',
|
||||||
product: productAccessFilter(userId),
|
product: productAccessFilter(userId),
|
||||||
},
|
},
|
||||||
orderBy: { completed_at: 'desc' },
|
orderBy: { completed_at: 'desc' },
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ export async function getAlignmentTrend(
|
||||||
): Promise<TrendPoint[]> {
|
): Promise<TrendPoint[]> {
|
||||||
const sprints = await prisma.sprint.findMany({
|
const sprints = await prisma.sprint.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'COMPLETED',
|
status: 'CLOSED',
|
||||||
product: productAccessFilter(userId),
|
product: productAccessFilter(userId),
|
||||||
},
|
},
|
||||||
orderBy: { completed_at: 'desc' },
|
orderBy: { completed_at: 'desc' },
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const TASK_DB_TO_API = {
|
||||||
REVIEW: 'review',
|
REVIEW: 'review',
|
||||||
DONE: 'done',
|
DONE: 'done',
|
||||||
FAILED: 'failed',
|
FAILED: 'failed',
|
||||||
|
EXCLUDED: 'excluded',
|
||||||
} as const satisfies Record<TaskStatus, string>
|
} as const satisfies Record<TaskStatus, string>
|
||||||
|
|
||||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
|
|
@ -23,6 +24,7 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||||
review: 'REVIEW',
|
review: 'REVIEW',
|
||||||
done: 'DONE',
|
done: 'DONE',
|
||||||
failed: 'FAILED',
|
failed: 'FAILED',
|
||||||
|
excluded: 'EXCLUDED',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORY_DB_TO_API = {
|
const STORY_DB_TO_API = {
|
||||||
|
|
@ -54,14 +56,16 @@ const PBI_API_TO_DB: Record<string, PbiStatus> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPRINT_DB_TO_API = {
|
const SPRINT_DB_TO_API = {
|
||||||
ACTIVE: 'active',
|
OPEN: 'open',
|
||||||
COMPLETED: 'completed',
|
CLOSED: 'closed',
|
||||||
|
ARCHIVED: 'archived',
|
||||||
FAILED: 'failed',
|
FAILED: 'failed',
|
||||||
} as const satisfies Record<SprintStatus, string>
|
} as const satisfies Record<SprintStatus, string>
|
||||||
|
|
||||||
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
|
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
|
||||||
active: 'ACTIVE',
|
open: 'OPEN',
|
||||||
completed: 'COMPLETED',
|
closed: 'CLOSED',
|
||||||
|
archived: 'ARCHIVED',
|
||||||
failed: 'FAILED',
|
failed: 'FAILED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,15 +131,15 @@ export async function propagateStatusUpwards(
|
||||||
|
|
||||||
let nextStatus: SprintStatus
|
let nextStatus: SprintStatus
|
||||||
if (anyPbiFailed) nextStatus = 'FAILED'
|
if (anyPbiFailed) nextStatus = 'FAILED'
|
||||||
else if (allPbisDone) nextStatus = 'COMPLETED'
|
else if (allPbisDone) nextStatus = 'CLOSED'
|
||||||
else nextStatus = 'ACTIVE'
|
else nextStatus = 'OPEN'
|
||||||
|
|
||||||
if (nextStatus !== sprint.status) {
|
if (nextStatus !== sprint.status) {
|
||||||
await tx.sprint.update({
|
await tx.sprint.update({
|
||||||
where: { id: sprint.id },
|
where: { id: sprint.id },
|
||||||
data: {
|
data: {
|
||||||
status: nextStatus,
|
status: nextStatus,
|
||||||
...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}),
|
...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
sprintChanged = true
|
sprintChanged = true
|
||||||
|
|
@ -149,7 +149,7 @@ export async function propagateStatusUpwards(
|
||||||
|
|
||||||
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
|
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
|
||||||
let sprintRunChanged = false
|
let sprintRunChanged = false
|
||||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') {
|
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') {
|
||||||
const job = await tx.claudeJob.findFirst({
|
const job = await tx.claudeJob.findFirst({
|
||||||
where: { task_id: taskId, sprint_run_id: { not: null } },
|
where: { task_id: taskId, sprint_run_id: { not: null } },
|
||||||
orderBy: { created_at: 'desc' },
|
orderBy: { created_at: 'desc' },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "scrum4me",
|
"name": "scrum4me",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "npx --yes kill-port 3000 || exit 0",
|
"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
|
REVIEW
|
||||||
DONE
|
DONE
|
||||||
FAILED
|
FAILED
|
||||||
|
EXCLUDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LogType {
|
enum LogType {
|
||||||
|
|
@ -75,8 +76,9 @@ enum TestStatus {
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SprintStatus {
|
enum SprintStatus {
|
||||||
ACTIVE
|
OPEN
|
||||||
COMPLETED
|
CLOSED
|
||||||
|
ARCHIVED
|
||||||
FAILED
|
FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,7 +304,7 @@ model Sprint {
|
||||||
product_id String
|
product_id String
|
||||||
code String @db.VarChar(30)
|
code String @db.VarChar(30)
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(ACTIVE)
|
status SprintStatus @default(OPEN)
|
||||||
start_date DateTime? @db.Date
|
start_date DateTime? @db.Date
|
||||||
end_date DateTime? @db.Date
|
end_date DateTime? @db.Date
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export type ParsedMilestone = {
|
||||||
title: string
|
title: string
|
||||||
goal: string
|
goal: string
|
||||||
priority: 1 | 2 | 3 | 4
|
priority: 1 | 2 | 3 | 4
|
||||||
sprint_status: 'ACTIVE' | 'COMPLETED'
|
sprint_status: 'OPEN' | 'CLOSED'
|
||||||
sort_order: number
|
sort_order: number
|
||||||
stories: ParsedStory[]
|
stories: ParsedStory[]
|
||||||
}
|
}
|
||||||
|
|
@ -66,19 +66,19 @@ const MILESTONE_GOAL: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
|
const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = {
|
||||||
M0: 'COMPLETED',
|
M0: 'CLOSED',
|
||||||
M1: 'COMPLETED',
|
M1: 'CLOSED',
|
||||||
M2: 'COMPLETED',
|
M2: 'CLOSED',
|
||||||
M3: 'COMPLETED',
|
M3: 'CLOSED',
|
||||||
'M3.5': 'COMPLETED',
|
'M3.5': 'CLOSED',
|
||||||
M4: 'COMPLETED',
|
M4: 'CLOSED',
|
||||||
M5: 'COMPLETED',
|
M5: 'CLOSED',
|
||||||
M6: 'COMPLETED',
|
M6: 'CLOSED',
|
||||||
M7: 'COMPLETED',
|
M7: 'CLOSED',
|
||||||
M8: 'COMPLETED',
|
M8: 'CLOSED',
|
||||||
M9: 'COMPLETED',
|
M9: 'CLOSED',
|
||||||
M10: 'COMPLETED',
|
M10: 'CLOSED',
|
||||||
M11: 'COMPLETED',
|
M11: 'CLOSED',
|
||||||
}
|
}
|
||||||
|
|
||||||
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/
|
||||||
|
|
@ -154,7 +154,7 @@ export async function loadBacklog(
|
||||||
title,
|
title,
|
||||||
goal: MILESTONE_GOAL[key] ?? title,
|
goal: MILESTONE_GOAL[key] ?? title,
|
||||||
priority: MILESTONE_PRIORITY[key] ?? 4,
|
priority: MILESTONE_PRIORITY[key] ?? 4,
|
||||||
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED',
|
sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'CLOSED',
|
||||||
sort_order: milestones.length + 1,
|
sort_order: milestones.length + 1,
|
||||||
stories: [],
|
stories: [],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ async function main() {
|
||||||
const forceOpen = ms.key === 'M3.5'
|
const forceOpen = ms.key === 'M3.5'
|
||||||
|
|
||||||
for (const s of ms.stories) {
|
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 effectivelyDone = !forceOpen && s.status === 'DONE'
|
||||||
const inSprint = isActive || effectivelyDone
|
const inSprint = isActive || effectivelyDone
|
||||||
const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
|
const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue