diff --git a/actions/sprints.ts b/actions/sprints.ts index 78fe7ce..499a87e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -54,6 +54,65 @@ export type CreateSprintWithSelectionResult = } | { error: string; code: number } +const updateSprintSchema = z.object({ + sprintId: z.string().min(1), + fields: z + .object({ + goal: z.string().min(1).max(2000).optional(), + startAt: z.string().date().nullable().optional(), + endAt: z.string().date().nullable().optional(), + }) + .refine( + (data) => Object.keys(data).length > 0, + 'Minstens één veld vereist', + ), +}) + +export type UpdateSprintInput = z.infer + +export type UpdateSprintResult = + | { success: true; sprintId: string } + | { error: string; code: number } + +export async function updateSprintAction( + input: UpdateSprintInput, +): Promise { + 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 parsed = updateSprintSchema.safeParse(input) + if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } + + const sprint = await prisma.sprint.findFirst({ + where: { + id: parsed.data.sprintId, + product: productAccessFilter(session.userId), + }, + select: { id: true, product_id: true }, + }) + if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } + + const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {} + if (parsed.data.fields.goal !== undefined) { + data.sprint_goal = parsed.data.fields.goal + } + if (parsed.data.fields.startAt !== undefined) { + data.start_date = parseDate(parsed.data.fields.startAt) + } + if (parsed.data.fields.endAt !== undefined) { + data.end_date = parseDate(parsed.data.fields.endAt) + } + + await prisma.sprint.update({ + where: { id: parsed.data.sprintId }, + data, + }) + revalidatePath(`/products/${sprint.product_id}`, 'layout') + + return { success: true, sprintId: parsed.data.sprintId } +} + const commitSprintMembershipSchema = z.object({ activeSprintId: z.string().min(1), adds: z.array(z.string()), @@ -344,10 +403,10 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } - const existing = await prisma.sprint.findFirst({ - 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 } + // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints + // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — + // cross-sprint-conflicts worden per-story afgevangen in de membership- + // commit-flow. const sprint = await createWithCodeRetry( () => generateNextSprintCode(parsed.data.productId), diff --git a/components/backlog/sprint-edit-dialog.tsx b/components/backlog/sprint-edit-dialog.tsx new file mode 100644 index 0000000..4842a8a --- /dev/null +++ b/components/backlog/sprint-edit-dialog.tsx @@ -0,0 +1,217 @@ +'use client' + +import { useRef, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +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 { updateSprintAction } from '@/actions/sprints' +import { debugProps } from '@/lib/debug' + +interface SprintEditDialogProps { + open: boolean + productId: string + sprint: { + id: string + code: string + sprint_goal: string + start_date?: string | null + end_date?: string | null + } + onOpenChange: (open: boolean) => void +} + +function toDateInput(value: string | null | undefined): string { + if (!value) return '' + // Accept ISO datetime or YYYY-MM-DD; output YYYY-MM-DD. + const d = new Date(value) + if (Number.isNaN(d.getTime())) return '' + return d.toLocaleDateString('en-CA') +} + +export function SprintEditDialog({ + open, + productId, + sprint, + onOpenChange, +}: SprintEditDialogProps) { + const [goal, setGoal] = useState(sprint.sprint_goal) + const [startDate, setStartDate] = useState(toDateInput(sprint.start_date)) + const [endDate, setEndDate] = useState(toDateInput(sprint.end_date)) + const [error, setError] = useState(null) + const [dirty, setDirty] = useState(false) + const [isPending, startTransition] = useTransition() + const formRef = useRef(null) + const router = useRouter() + + function reset() { + setGoal(sprint.sprint_goal) + setStartDate(toDateInput(sprint.start_date)) + setEndDate(toDateInput(sprint.end_date)) + setError(null) + setDirty(false) + } + + const closeGuard = useDirtyCloseGuard(dirty, () => { + onOpenChange(false) + reset() + }) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const trimmed = goal.trim() + if (!trimmed) return + setError(null) + startTransition(async () => { + const result = await updateSprintAction({ + sprintId: sprint.id, + fields: { + goal: trimmed, + startAt: startDate || null, + endAt: endDate || null, + }, + }) + if ('error' in result) { + setError(result.error) + toast.error(result.error) + return + } + toast.success('Sprint bijgewerkt') + onOpenChange(false) + router.refresh() + }) + } + + const handleKeyDown = useDialogSubmitShortcut(() => + formRef.current?.requestSubmit(), + ) + + return ( + <> + { + if (!o) closeGuard.attemptClose() + else onOpenChange(o) + }} + > + +
+ + Sprint {sprint.code} bewerken + +

+ Wijzig sprint-doel en datums. Voor afronding (per-story DONE/OPEN + beslissing) ga naar de sprint-pagina. +

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