Team
diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx
index 3e3a36c..ddedcce 100644
--- a/app/(app)/products/[id]/sprint/page.tsx
+++ b/app/(app)/products/[id]/sprint/page.tsx
@@ -6,6 +6,7 @@ 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 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'
@@ -33,7 +34,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
if (!product) notFound()
const sprint = await prisma.sprint.findFirst({
- where: { product_id: id, status: 'ACTIVE' },
+ where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
select: {
id: true,
sprint_goal: true,
@@ -44,6 +45,14 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
})
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 },
+ })
+
// Sprint stories with full task data and assignee
const [sprintStories, productMembers] = await Promise.all([
prisma.story.findMany({
@@ -147,6 +156,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
sprintStories={sprintStoryItems}
/>
+
+
+
+
= {
+ SPRINT: 'Per sprint — één PR voor de hele sprint, klaar voor review aan eind',
+ STORY: 'Per story — auto-merge na CI groen, één PR per story',
+}
+
+export function PrStrategySelect({ productId, initialValue }: PrStrategySelectProps) {
+ const [value, setValue] = useState(initialValue)
+ const [isPending, startTransition] = useTransition()
+
+ function handleChange(next: string | null) {
+ if (next !== 'SPRINT' && next !== 'STORY') return
+ if (next === value) return
+ const previous = value
+ setValue(next)
+ startTransition(async () => {
+ const result = await updatePrStrategyAction(productId, next)
+ if ('error' in result && result.error) {
+ setValue(previous)
+ toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
+ }
+ })
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/components/sprint/sprint-run-controls.tsx b/components/sprint/sprint-run-controls.tsx
new file mode 100644
index 0000000..0ea8f47
--- /dev/null
+++ b/components/sprint/sprint-run-controls.tsx
@@ -0,0 +1,189 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { toast } from 'sonner'
+import {
+ startSprintRunAction,
+ resumeSprintAction,
+ cancelSprintRunAction,
+ type PreFlightBlocker,
+} from '@/actions/sprint-runs'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+
+type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED'
+type SprintRunStatusValue =
+ | 'QUEUED'
+ | 'RUNNING'
+ | 'PAUSED'
+ | 'DONE'
+ | 'FAILED'
+ | 'CANCELLED'
+ | null
+
+interface Props {
+ sprintId: string
+ productId: string
+ sprintStatus: SprintStatusValue
+ activeSprintRunId: string | null
+ activeSprintRunStatus: SprintRunStatusValue
+ isDemo: boolean
+}
+
+const BLOCKER_LABELS: Record = {
+ task_no_plan: 'Task zonder implementation plan',
+ open_question: 'Openstaande vraag aan jou',
+ pbi_blocked: 'PBI is geblokkeerd of gefaald',
+}
+
+function blockerHref(productId: string, blocker: PreFlightBlocker): string {
+ switch (blocker.type) {
+ case 'task_no_plan':
+ return `/products/${productId}/sprint?editTask=${blocker.id}`
+ case 'open_question':
+ return `/products/${productId}/sprint`
+ case 'pbi_blocked':
+ return `/products/${productId}`
+ }
+}
+
+export function SprintRunControls({
+ sprintId,
+ productId,
+ sprintStatus,
+ activeSprintRunId,
+ activeSprintRunStatus,
+ isDemo,
+}: Props) {
+ const [pending, startTransition] = useTransition()
+ const [blockers, setBlockers] = useState(null)
+
+ const hasActiveRun =
+ activeSprintRunId !== null &&
+ (activeSprintRunStatus === 'QUEUED' ||
+ activeSprintRunStatus === 'RUNNING' ||
+ activeSprintRunStatus === 'PAUSED')
+
+ const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun
+ const canResume = sprintStatus === 'FAILED'
+ const canCancel = hasActiveRun
+
+ function handleStart() {
+ startTransition(async () => {
+ const result = await startSprintRunAction({ sprint_id: sprintId })
+ if (result.ok) {
+ toast.success(`Sprint gestart (${result.jobs_count} taak(s) klaar)`)
+ } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) {
+ setBlockers(result.blockers)
+ } else {
+ toast.error(result.error)
+ }
+ })
+ }
+
+ function handleResume() {
+ startTransition(async () => {
+ const result = await resumeSprintAction({ sprint_id: sprintId })
+ if (result.ok) {
+ toast.success(`Sprint hervat (${result.jobs_count} taak(s) klaar)`)
+ } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) {
+ setBlockers(result.blockers)
+ } else {
+ toast.error(result.error)
+ }
+ })
+ }
+
+ function handleCancel() {
+ if (!activeSprintRunId) return
+ if (!confirm('Sprint annuleren? Openstaande taken blijven TO_DO.')) return
+ startTransition(async () => {
+ const result = await cancelSprintRunAction({ sprint_run_id: activeSprintRunId })
+ if (result.ok) toast.success('Sprint geannuleerd')
+ else toast.error(result.error)
+ })
+ }
+
+ return (
+ <>
+
+ {canStart && (
+
+ )}
+ {canResume && (
+
+ )}
+ {canCancel && (
+
+ )}
+
+
+
+ >
+ )
+}