feat(sprint-dialogs): conform aan dialog-pattern + entity-profile

Story 5 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- lib/schemas/sprint.ts — gedeelde zod-schemas (create/dates/goal)
- actions/sprints.ts — code+fieldErrors voor 422; code: 403 voor
  auth/demo errors
- StartSprintButton dialog: useDirtyCloseGuard, useDialogSubmitShortcut,
  entityDialog* layout-classes; DemoTooltip op trigger; veld-niveau
  errors via fieldErrors
- SprintHeader's date- en complete-dialogen: zelfde behandeling; date-
  dialog krijgt dirty-guard, complete-dialog krijgt DemoTooltip op
  bevestigen
- docs/specs/dialogs/sprint.md — entity-profile dat alle drie de modes
  documenteert; consolidatie naar één SprintDialog component bewust
  uitgesteld
- Sprint-dates tests aangepast aan nieuwe action-shape

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:30:46 +02:00
parent 01e77fc560
commit 784791d8f9
7 changed files with 320 additions and 125 deletions

View file

@ -53,10 +53,9 @@ describe('createSprintAction — date validation', () => {
it('rejects end_date before start_date', async () => {
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
const result = await createSprintAction(undefined, fd)
expect(result.error).toBeTruthy()
const errors = result.error as Record<string, string[]>
expect(errors.end_date?.[0]).toContain('Einddatum')
const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.code).toBe(422)
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
})
it('accepts no dates (both optional)', async () => {
@ -81,10 +80,9 @@ describe('updateSprintDatesAction — date validation', () => {
it('rejects end_date before start_date', async () => {
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
const result = await updateSprintDatesAction(undefined, fd)
expect(result.error).toBeTruthy()
const errors = result.error as Record<string, string[]>
expect(errors.end_date?.[0]).toContain('Einddatum')
const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.code).toBe(422)
expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
})
it('blocks demo users', async () => {

View file

@ -3,10 +3,14 @@
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 { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import {
createSprintSchema,
updateSprintDatesSchema,
updateSprintGoalSchema,
} from '@/lib/schemas/sprint'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -16,39 +20,34 @@ function hasDuplicateIds(ids: string[]) {
return new Set(ids).size !== ids.length
}
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) {
if (data.start_date && data.end_date && data.end_date < data.start_date) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
}
}
type SprintFieldErrors = Record<string, string[]>
export async function createSprintAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = z.object({
productId: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
start_date: dateField,
end_date: dateField,
}).superRefine(validateDateOrder).safeParse({
const parsed = createSprintSchema.safeParse({
productId: formData.get('productId'),
sprint_goal: formData.get('sprint_goal'),
start_date: formData.get('start_date'),
end_date: formData.get('end_date'),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden' }
if (!product) return { error: 'Product niet gevonden', code: 403 }
const existing = await prisma.sprint.findFirst({
where: { product_id: parsed.data.productId, status: 'ACTIVE' },
})
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id }
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
const sprint = await prisma.sprint.create({
data: {
@ -66,24 +65,26 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = z.object({
id: z.string(),
start_date: dateField,
end_date: dateField,
}).superRefine(validateDateOrder).safeParse({
const parsed = updateSprintDatesSchema.safeParse({
id: formData.get('id'),
start_date: formData.get('start_date'),
end_date: formData.get('end_date'),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const sprint = await prisma.sprint.findFirst({
where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
})
if (!sprint) return { error: 'Sprint niet gevonden' }
if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
await prisma.sprint.update({
where: { id: parsed.data.id },
@ -95,19 +96,27 @@ export async function updateSprintDatesAction(_prevState: unknown, formData: For
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const id = formData.get('id') as string
const sprint_goal = formData.get('sprint_goal') as string
if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' }
const parsed = updateSprintGoalSchema.safeParse({
id: formData.get('id'),
sprint_goal: formData.get('sprint_goal'),
})
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const sprint = await prisma.sprint.findFirst({
where: { id, product: productAccessFilter(session.userId) },
where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
})
if (!sprint) return { error: 'Sprint niet gevonden' }
if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
await prisma.sprint.update({ where: { id }, data: { sprint_goal } })
await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } })
revalidatePath(`/products/${sprint.product_id}/sprint`)
return { success: true }
}

View file

@ -1,17 +1,25 @@
'use client'
import { useState, useTransition, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useState, useTransition, useActionState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
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 { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints'
import type { SprintStory } from './sprint-backlog'
@ -31,9 +39,11 @@ interface SprintHeaderProps {
sprintStories: SprintStory[]
}
function SaveGoalButton() {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
}
function toDateInputValue(d: Date | null) {
@ -47,33 +57,39 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
const [completeOpen, setCompleteOpen] = useState(false)
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
const [isCompleting, startCompleting] = useTransition()
const [datesDirty, setDatesDirty] = useState(false)
const datesFormRef = useRef<HTMLFormElement>(null)
const [, goalFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintGoalAction(_prev, fd)
const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updateSprintGoalAction(_prev, fd) as ActionResult
if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
else if (result?.error) toast.error(result.error)
return result
},
undefined
undefined,
)
const [datesState, datesFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintDatesAction(_prev, fd)
if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
const [datesState, datesFormAction, datesPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updateSprintDatesAction(_prev, fd) as ActionResult
if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') }
else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result
},
undefined
undefined,
)
const datesFieldError = (field: string) => datesState?.fieldErrors?.[field]?.[0]
const datesCloseGuard = useDirtyCloseGuard(datesDirty, () => setEditingDates(false))
const datesKeyDown = useDialogSubmitShortcut(() => datesFormRef.current?.requestSubmit())
function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
setDecisions(prev => ({ ...prev, [storyId]: value }))
}
function handleComplete() {
// Default: stories without explicit decision → OPEN
const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {}
sprintStories.forEach(s => {
finalDecisions[s.id] = decisions[s.id] ?? 'OPEN'
@ -101,7 +117,9 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
<input type="hidden" name="id" value={sprint.id} />
<Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus />
<div className="flex flex-col gap-1">
<SaveGoalButton />
<Button type="submit" size="sm" disabled={goalPending}>
{goalPending ? 'Opslaan…' : 'Opslaan'}
</Button>
<Button type="button" size="sm" variant="ghost" aria-label="Annuleer bewerken" onClick={() => setEditingGoal(false)}>×</Button>
</div>
</form>
@ -131,51 +149,66 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
{/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Sprint datums instellen</DialogTitle>
</DialogHeader>
<form action={datesFormAction} className="space-y-4 p-1">
<Dialog open={editingDates} onOpenChange={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={datesKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint datums instellen</DialogTitle>
</div>
<form
ref={datesFormRef}
id="sprint-dates-form"
action={datesFormAction}
onChange={() => setDatesDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="id" value={sprint.id} />
<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" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} 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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
{datesFieldError('start_date') && (
<p className="text-xs text-error">{datesFieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} 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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
{datesFieldError('end_date') && (
<p className="text-xs text-error">{datesFieldError('end_date')}</p>
)}
</div>
</div>
{typeof datesState?.error === 'string' && (
<p className="text-xs text-error">{datesState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
<Button type="submit">Opslaan</Button>
</div>
</form>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={datesCloseGuard.attemptClose} disabled={datesPending}>
Annuleren
</Button>
<Button type="submit" form="sprint-dates-form" disabled={datesPending}>
{datesPending ? '…' : 'Opslaan'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={datesCloseGuard} />
{/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Sprint afronden</DialogTitle>
</DialogHeader>
<div className="space-y-4 p-1">
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<p className="text-sm text-muted-foreground">
Geef per story aan wat er mee moet gebeuren:
</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="space-y-2">
{sprintStories.map(story => (
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
@ -197,11 +230,17 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
))}
</div>
</div>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button>
<Button disabled={isCompleting} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
<Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button disabled={isCompleting || isDemo} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
</Button>
</DemoTooltip>
</div>
</div>
</DialogContent>

View file

@ -1,62 +1,92 @@
'use client'
import { useState, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useState, useActionState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
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 { createSprintAction } from '@/actions/sprints'
interface StartSprintButtonProps {
productId: string
isDemo?: boolean
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
)
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
sprintId?: string
}
export function StartSprintButton({ productId }: StartSprintButtonProps) {
export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
const [open, setOpen] = useState(false)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter()
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createSprintAction(_prev, fd)
if (result.success) {
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await createSprintAction(_prev, fd) as ActionResult
if (result?.success) {
setOpen(false)
setDirty(false)
router.push(`/products/${productId}/sprint`)
} else if (result?.code !== 422 && result?.error) {
// Toast handled by caller; here we just keep the form open
}
return result
},
undefined
undefined,
)
const globalError = typeof state?.error === 'string' ? state.error : undefined
const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0]
const globalError = state?.code !== 422 ? state?.error : undefined
const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false))
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return (
<>
<Button size="sm" onClick={() => setOpen(true)}>
Sprint starten
</Button>
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo}>
Sprint starten
</Button>
</DemoTooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Nieuwe Sprint starten</DialogTitle>
</DialogHeader>
<Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle>
</div>
<form action={formAction} className="space-y-4 p-1">
<form
ref={formRef}
id="start-sprint-form"
action={formAction}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="productId" value={productId} />
<div className="space-y-1.5">
@ -69,9 +99,11 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
aria-invalid={!!fieldError('sprint_goal')}
className={fieldError('sprint_goal') ? 'border-error' : ''}
/>
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).sprint_goal && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).sprint_goal[0]}</p>
{fieldError('sprint_goal') && (
<p className="text-xs text-error">{fieldError('sprint_goal')}</p>
)}
</div>
@ -79,15 +111,15 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" 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" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
{fieldError('start_date') && (
<p className="text-xs text-error">{fieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" 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" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
{fieldError('end_date') && (
<p className="text-xs text-error">{fieldError('end_date')}</p>
)}
</div>
</div>
@ -97,16 +129,22 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
{globalError}
</div>
)}
</form>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
<SubmitButton />
<Button type="submit" form="start-sprint-form" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -25,6 +25,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
|---|---|---|
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-04 |
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 |

View file

@ -0,0 +1,72 @@
---
title: "Sprint Dialogs Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# Sprint Dialogs Profiel
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de Sprint-specifieke afwijkingen en keuzes.
Sprint heeft drie dialog-flows verspreid over twee componenten:
| Flow | Locatie | Mode |
|---|---|---|
| Sprint starten | `components/sprint/start-sprint-button.tsx` | create |
| Datums bewerken | `components/sprint/sprint-header.tsx` (inline) | edit |
| Sprint afronden | `components/sprint/sprint-header.tsx` (inline) | actie-bevestiging |
Daarnaast bestaat een **inline edit-form** voor de Sprint Goal in `sprint-header.tsx` — dat is geen dialog (geen modale overlay) maar een toggleable form-row.
## Velden
### Create / Edit dates
| Veld | Type | Validatie |
|---|---|---|
| `sprint_goal` | string (alleen create) | min 1, max 500 |
| `start_date` | date \| null | optioneel; `end_date >= start_date` |
| `end_date` | date \| null | optioneel; `end_date >= start_date` |
### Complete sprint
Geen form-velden. Per story-rij in de sprint kiest de gebruiker `'DONE'` of `'OPEN'` (default `'OPEN'`). De decisions-map wordt direct aan `completeSprintAction` doorgegeven.
## URL- of state-pattern
- Gekozen: **state-based** (§11.2)
- Reden: alle dialogen zijn local-state in hun parent-component (button of header). Sprint heeft geen deep-link-bare detail-pagina voor zijn dialogen.
## Server actions
- `createSprintAction(_prev, fd)``actions/sprints.ts` — revalidate `/products/${productId}`
- `updateSprintDatesAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
- `updateSprintGoalAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
- `completeSprintAction(sprintId, decisions)` — niet form-based; directe argumenten
- Alle hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter`/`getAccessibleProduct` voor scope
- Resultaat-shape: `{ success: true, ... }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }`
## Foutcodes
| Code | Wanneer | UI |
|---|---|---|
| 422 | zod-validatie of date-order constraint | `fieldErrors` onder de velden, geen toast |
| 403 | niet ingelogd, demo-modus, of geen toegang | toast met message |
## Schema
`lib/schemas/sprint.ts` exporteert:
- `createSprintSchema` — productId, sprint_goal, start_date, end_date
- `updateSprintDatesSchema` — id, start_date, end_date
- `updateSprintGoalSchema` — id, sprint_goal
- `validateDateOrder` — refinement gebruikt door beide date-schemas
Alle drie de actions importeren hier; geen inline schemas meer.
## Bewust NIET in v1
- ❌ **Eén consolideerde `SprintDialog`-component** met `mode: 'create' | 'edit-dates' | 'complete'` — overwogen tijdens story 5 maar niet uitgevoerd; de dialogen leven natuurlijker in hun parent-component (button / header) en worden niet hergebruikt elders. Indien een vierde sprint-dialog ontstaat, hernieuw deze afweging.
- ❌ Bewerken van de Sprint Goal vanuit deze dialogen — gebeurt via een inline-form in `sprint-header.tsx` (toggleable, geen modal)
- ❌ Sprint-templates / kopiëren van vorige sprint

38
lib/schemas/sprint.ts Normal file
View file

@ -0,0 +1,38 @@
import { z } from 'zod'
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
export function validateDateOrder(
data: { start_date: Date | null; end_date: Date | null },
ctx: z.RefinementCtx,
) {
if (data.start_date && data.end_date && data.end_date < data.start_date) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
}
}
export const createSprintSchema = z
.object({
productId: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
start_date: dateField,
end_date: dateField,
})
.superRefine(validateDateOrder)
export const updateSprintDatesSchema = z
.object({
id: z.string(),
start_date: dateField,
end_date: dateField,
})
.superRefine(validateDateOrder)
export const updateSprintGoalSchema = z.object({
id: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
})
export type CreateSprintInput = z.infer<typeof createSprintSchema>
export type UpdateSprintDatesInput = z.infer<typeof updateSprintDatesSchema>
export type UpdateSprintGoalInput = z.infer<typeof updateSprintGoalSchema>