- 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) <noreply@anthropic.com>
187 lines
5.9 KiB
TypeScript
187 lines
5.9 KiB
TypeScript
'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<string | null>(null)
|
|
const [dirty, setDirty] = useState(false)
|
|
const [isPending, startTransition] = useTransition()
|
|
const formRef = useRef<HTMLFormElement>(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 (
|
|
<>
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(o) => {
|
|
if (!o) closeGuard.attemptClose()
|
|
else onOpenChange(o)
|
|
}}
|
|
>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
onKeyDown={handleKeyDown}
|
|
className={entityDialogContentClasses}
|
|
>
|
|
<div className={entityDialogHeaderClasses}>
|
|
<DialogTitle className="text-xl font-semibold">Nieuwe sprint</DialogTitle>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst
|
|
</p>
|
|
</div>
|
|
|
|
<form
|
|
ref={formRef}
|
|
id="new-sprint-form"
|
|
onSubmit={handleSubmit}
|
|
onChange={() => setDirty(true)}
|
|
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
|
|
>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-foreground">
|
|
Sprint Goal <span className="text-error">*</span>
|
|
</label>
|
|
<Textarea
|
|
value={sprintGoal}
|
|
onChange={(e) => setSprintGoal(e.target.value)}
|
|
required
|
|
rows={3}
|
|
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<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"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
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"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-sm font-medium text-foreground">Einddatum</label>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
<div className={entityDialogFooterClasses}>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={closeGuard.attemptClose}
|
|
disabled={isPending}
|
|
>
|
|
Annuleren
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
form="new-sprint-form"
|
|
disabled={isPending || !sprintGoal.trim() || pbiIds.length === 0}
|
|
>
|
|
{isPending ? 'Aanmaken…' : 'Sprint aanmaken'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
|
</>
|
|
)
|
|
}
|