diff --git a/actions/products.ts b/actions/products.ts index 9a0856b..d25ede0 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -396,3 +396,27 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) { revalidatePath(`/products/${id}/settings`) return { success: true } } + +export async function updatePrStrategyAction( + id: string, + pr_strategy: 'SPRINT' | 'STORY', +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = z + .object({ pr_strategy: z.enum(['SPRINT', 'STORY']) }) + .safeParse({ pr_strategy }) + if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' } + + const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } }) + if (!product) return { error: 'Product niet gevonden' } + + await prisma.product.update({ + where: { id }, + data: { pr_strategy: parsed.data.pr_strategy }, + }) + revalidatePath(`/products/${id}/settings`) + return { success: true } +} diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx index 6be4800..046a994 100644 --- a/app/(app)/products/[id]/settings/page.tsx +++ b/app/(app)/products/[id]/settings/page.tsx @@ -8,6 +8,7 @@ import { ArchiveProductButton } from '@/components/products/archive-product-butt import { TeamManager } from '@/components/products/team-manager' import { updateProductFormAction } from '@/actions/products' import { AutoPrToggle } from '@/components/products/auto-pr-toggle' +import { PrStrategySelect } from '@/components/products/pr-strategy-select' import Link from 'next/link' interface Props { @@ -66,6 +67,17 @@ export default async function ProductSettingsPage({ params }: Props) { +
+
+

PR-strategie

+

+ Bepaalt hoe de sprint zijn werk oplevert: één PR voor de hele sprint + of een PR per story die automatisch wordt gemerged na groene CI. +

+
+ +
+

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 && ( + + )} +
+ + { if (!open) setBlockers(null) }}> + + + Sprint kan nog niet starten + + Los eerst onderstaande punten op. Klik op een item om er direct naar + te navigeren. + + + +
    + {blockers?.map((b, i) => ( +
  • +
    + {BLOCKER_LABELS[b.type]} +
    + + {b.label} + +
  • + ))} +
+ + + + +
+
+ + ) +}