From 8fbf79441aea7ce1d9df89ca200a41904437035b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Fri, 8 May 2026 00:09:04 +0200 Subject: [PATCH] feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- __tests__/actions/sprint-runs.test.ts | 8 +- __tests__/actions/sprints-cascade.test.ts | 2 +- __tests__/actions/story-claim.test.ts | 2 +- __tests__/api/next-story.test.ts | 2 +- __tests__/api/security.test.ts | 2 +- __tests__/api/sprint-tasks.test.ts | 2 +- __tests__/lib/tasks-status-update.test.ts | 10 +- actions/active-sprint.ts | 42 ++++ actions/sprint-runs.ts | 7 +- actions/sprints.ts | 81 ++++++- actions/stories.ts | 2 +- app/(app)/insights/page.tsx | 2 +- app/(app)/layout.tsx | 37 ++- app/(app)/products/[id]/page.tsx | 2 +- app/(app)/products/[id]/solo/page.tsx | 2 +- .../[id]/sprint/{ => [sprintId]}/loading.tsx | 0 .../products/[id]/sprint/[sprintId]/page.tsx | 223 ++++++++++++++++++ .../{ => [sprintId]}/planning/loading.tsx | 0 .../sprint/{ => [sprintId]}/planning/page.tsx | 6 +- app/(app)/products/[id]/sprint/page.tsx | 219 +---------------- app/(mobile)/m/products/[id]/solo/page.tsx | 2 +- app/_components/tasks/status-select.tsx | 3 +- app/api/products/[id]/claude-context/route.ts | 2 +- app/api/products/[id]/next-story/route.ts | 2 +- app/api/realtime/solo/route.ts | 2 +- components/backlog/pbi-list.tsx | 117 +++++++++ components/shared/nav-bar.tsx | 115 ++++++++- components/sprint/new-sprint-dialog.tsx | 187 +++++++++++++++ components/sprint/sprint-run-controls.tsx | 4 +- components/sprint/task-list.tsx | 14 +- docs/erd.svg | 2 +- lib/active-sprint.ts | 66 ++++++ lib/insights/burndown.ts | 2 +- lib/insights/sprint-status.ts | 2 +- lib/insights/velocity.ts | 2 +- lib/insights/verify-stats.ts | 2 +- lib/task-status.ts | 12 +- lib/tasks-status-update.ts | 8 +- package.json | 2 +- .../migration.sql | 19 ++ prisma/schema.prisma | 8 +- prisma/seed-data/parse-backlog.ts | 30 +-- prisma/seed.ts | 2 +- 43 files changed, 966 insertions(+), 290 deletions(-) create mode 100644 actions/active-sprint.ts rename app/(app)/products/[id]/sprint/{ => [sprintId]}/loading.tsx (100%) create mode 100644 app/(app)/products/[id]/sprint/[sprintId]/page.tsx rename app/(app)/products/[id]/sprint/{ => [sprintId]}/planning/loading.tsx (100%) rename app/(app)/products/[id]/sprint/{ => [sprintId]}/planning/page.tsx (50%) create mode 100644 components/sprint/new-sprint-dialog.tsx create mode 100644 lib/active-sprint.ts create mode 100644 prisma/migrations/20260507210000_sprint_lifecycle/migration.sql diff --git a/__tests__/actions/sprint-runs.test.ts b/__tests__/actions/sprint-runs.test.ts index 9defa50..a939d6e 100644 --- a/__tests__/actions/sprint-runs.test.ts +++ b/__tests__/actions/sprint-runs.test.ts @@ -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' }) diff --git a/__tests__/actions/sprints-cascade.test.ts b/__tests__/actions/sprints-cascade.test.ts index b302716..b501959 100644 --- a/__tests__/actions/sprints-cascade.test.ts +++ b/__tests__/actions/sprints-cascade.test.ts @@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as { $transaction: ReturnType } -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() diff --git a/__tests__/actions/story-claim.test.ts b/__tests__/actions/story-claim.test.ts index 6fba5e5..bfcc402 100644 --- a/__tests__/actions/story-claim.test.ts +++ b/__tests__/actions/story-claim.test.ts @@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType 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() diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index cc5a86d..4c614e9 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType -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', diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index ca60059..467e248 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -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' }]), }), diff --git a/__tests__/api/sprint-tasks.test.ts b/__tests__/api/sprint-tasks.test.ts index c496e0d..c3ac8a9 100644 --- a/__tests__/api/sprint-tasks.test.ts +++ b/__tests__/api/sprint-tasks.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType -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 { diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index 814ec6b..ccaa2f6 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -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 }).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 }).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 }).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 }).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' }, diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts new file mode 100644 index 0000000..b391d0a --- /dev/null +++ b/actions/active-sprint.ts @@ -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(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 } +} diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts index 57f76e4..7b4b87a 100644 --- a/actions/sprint-runs.ts +++ b/actions/sprint-runs.ts @@ -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 { // 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) diff --git a/actions/sprints.ts b/actions/sprints.ts index 3da4eda..dc5d55e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -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(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 } +} diff --git a/actions/stories.ts b/actions/stories.ts index b66ec01..bcc88fc 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -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' } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 4c646a1..9b90641 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -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, diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 6fe0197..806d725 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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} /> diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index d635e16..a91b8f1 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -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 } }), ]) diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 6c03d5b..72aa65b 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -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) { diff --git a/app/(app)/products/[id]/sprint/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx similarity index 100% rename from app/(app)/products/[id]/sprint/loading.tsx rename to app/(app)/products/[id]/sprint/[sprintId]/loading.tsx diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx new file mode 100644 index 0000000..bdac0d5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -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 = {} + 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 ( +
+ + +
+ +
+ +
+ +
+ +
+ + ← Product Backlog + +
+ + {newTask && ( + + )} + + {editTask && !newTask && ( + }> + + + )} +
+ ) +} diff --git a/app/(app)/products/[id]/sprint/planning/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx similarity index 100% rename from app/(app)/products/[id]/sprint/planning/loading.tsx rename to app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx diff --git a/app/(app)/products/[id]/sprint/planning/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx similarity index 50% rename from app/(app)/products/[id]/sprint/planning/page.tsx rename to app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx index 8256d6f..d2a017a 100644 --- a/app/(app)/products/[id]/sprint/planning/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx @@ -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}`) } diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 7f09296..cea6993 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -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 = {} - 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 ( -
- - -
- -
- -
- -
- -
- - ← Product Backlog - -
- - {newTask && ( - - )} - - {editTask && !newTask && ( - }> - - - )} -
- ) + redirect(`/products/${id}/sprint/${active.id}`) } diff --git a/app/(mobile)/m/products/[id]/solo/page.tsx b/app/(mobile)/m/products/[id]/solo/page.tsx index 132e980..8007edf 100644 --- a/app/(mobile)/m/products/[id]/solo/page.tsx +++ b/app/(mobile)/m/products/[id]/solo/page.tsx @@ -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) { diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx index 9409614..298c350 100644 --- a/app/_components/tasks/status-select.tsx +++ b/app/_components/tasks/status-select.tsx @@ -15,10 +15,11 @@ const STATUS_CONFIG: Record = { 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 ( diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts index f5a5fd5..3611c64 100644 --- a/app/api/products/[id]/claude-context/route.ts +++ b/app/api/products/[id]/claude-context/route.ts @@ -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({ diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index cbd6944..4ab4529 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -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 }) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index dbf820b..40a0b01 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -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' }, }) diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 7587e13..588e0b0 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -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 ( + { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }} + badge={ + + {PBI_STATUS_LABELS[pbi.status]} + + } + actions={ + + } + /> + ) + } + return ( (null) const [activeDragId, setActiveDragId] = useState(null) + const [selectionMode, setSelectionMode] = useState(false) + const [selectedIds, setSelectedIds] = useState>(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) { + + + + + + + )} + setDialogState(null)} isDemo={isDemo} /> + + { + setNewSprintOpen(open) + if (!open) { + // Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan + } + }} + onCreated={() => { + setNewSprintOpen(false) + exitSelection() + }} + /> ) } diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 61365b4..041a22d 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -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 = { + open: 'Open', + closed: 'Gesloten', + archived: 'Gearchiveerd', + failed: 'Mislukt', +} + +const SPRINT_STATUS_BADGE: Record = { + 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({ ) } + const href = `/products/${activeId}/sprint/${activeSprint!.id}` return navLink(href, 'Sprint', isActive) } @@ -149,8 +190,8 @@ export function NavBar({ - {/* Midden: actief product dropdown */} -
+ {/* Midden: actief product + sprint, gestapeld */} +
{activeProduct ? ( Geen actief product )} + + {activeProduct && ( + sprints.length === 0 ? ( + + + + Geen sprints + + Maak een sprint aan vanuit de Product Backlog + + + ) : ( + + + + {activeSprint ? activeSprint.code : 'Selecteer sprint'} + + {activeSprint && ( + + {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]} + + )} + + + + {sprints.map(s => ( + 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', + )} + > + {s.code} + + {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]} + + + ))} + + + ) + )}
{/* Rechts: solo-status + notifications + account-menu */} diff --git a/components/sprint/new-sprint-dialog.tsx b/components/sprint/new-sprint-dialog.tsx new file mode 100644 index 0000000..21413ea --- /dev/null +++ b/components/sprint/new-sprint-dialog.tsx @@ -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(null) + const [dirty, setDirty] = useState(false) + const [isPending, startTransition] = useTransition() + const formRef = useRef(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 ( + <> + { + if (!o) closeGuard.attemptClose() + else onOpenChange(o) + }} + > + +
+ Nieuwe sprint +

+ {pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst +

+
+ +
setDirty(true)} + className="flex-1 overflow-y-auto px-6 py-6 space-y-6" + > +
+ +