actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
5.8 KiB
TypeScript
202 lines
5.8 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo, useState, useTransition } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { toast } from 'sonner'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
import type { PendingSprintDraft } from '@/lib/user-settings'
|
|
import { createSprintWithSelectionAction } from '@/actions/sprints'
|
|
import { debugProps } from '@/lib/debug'
|
|
|
|
interface SprintDefinitionBannerProps {
|
|
productId: string
|
|
draft: PendingSprintDraft
|
|
}
|
|
|
|
type DraftCounts = {
|
|
pbiCount: number
|
|
storyCount: number
|
|
hasUnknownTotal: boolean
|
|
}
|
|
|
|
function computeCounts(
|
|
draft: PendingSprintDraft,
|
|
pbiSummary: Record<
|
|
string,
|
|
{ totalStoryCount: number; inActiveSprintStoryCount: number }
|
|
>,
|
|
): DraftCounts {
|
|
let pbiCount = 0
|
|
let storyCount = 0
|
|
let hasUnknownTotal = false
|
|
|
|
const seenPbis = new Set<string>()
|
|
|
|
for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) {
|
|
if (intent === 'all') {
|
|
seenPbis.add(pbiId)
|
|
const summary = pbiSummary[pbiId]
|
|
const override = draft.storyOverrides[pbiId]
|
|
if (!summary) {
|
|
hasUnknownTotal = true
|
|
continue
|
|
}
|
|
const removed = override?.remove.length ?? 0
|
|
storyCount += Math.max(0, summary.totalStoryCount - removed)
|
|
}
|
|
}
|
|
|
|
for (const [pbiId, override] of Object.entries(draft.storyOverrides)) {
|
|
if (override.add.length === 0) continue
|
|
seenPbis.add(pbiId)
|
|
storyCount += override.add.length
|
|
}
|
|
|
|
pbiCount = seenPbis.size
|
|
return { pbiCount, storyCount, hasUnknownTotal }
|
|
}
|
|
|
|
export function SprintDefinitionBanner({
|
|
productId,
|
|
draft,
|
|
}: SprintDefinitionBannerProps) {
|
|
const clearPendingSprintDraft = useUserSettingsStore(
|
|
(s) => s.clearPendingSprintDraft,
|
|
)
|
|
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
|
|
const router = useRouter()
|
|
const [isPending, startTransition] = useTransition()
|
|
const [confirmCancel, setConfirmCancel] = useState(false)
|
|
|
|
const counts = useMemo(
|
|
() => computeCounts(draft, pbiSummary),
|
|
[draft, pbiSummary],
|
|
)
|
|
|
|
function handleCancel() {
|
|
setConfirmCancel(true)
|
|
}
|
|
|
|
function confirmCancelAction() {
|
|
setConfirmCancel(false)
|
|
startTransition(async () => {
|
|
try {
|
|
await clearPendingSprintDraft(productId)
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : 'Annuleren mislukt'
|
|
toast.error(message)
|
|
}
|
|
})
|
|
}
|
|
|
|
function handleCreate() {
|
|
startTransition(async () => {
|
|
const result = await createSprintWithSelectionAction({
|
|
productId,
|
|
metadata: {
|
|
goal: draft.goal,
|
|
startAt: draft.startAt,
|
|
endAt: draft.endAt,
|
|
},
|
|
pbiIntent: draft.pbiIntent,
|
|
storyOverrides: draft.storyOverrides,
|
|
})
|
|
if ('error' in result) {
|
|
toast.error(result.error)
|
|
return
|
|
}
|
|
const { conflicts } = result
|
|
if (conflicts.notEligible.length > 0) {
|
|
toast.warning(
|
|
`${conflicts.notEligible.length} stor${
|
|
conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn'
|
|
} overgeslagen (al in een andere sprint of afgerond).`,
|
|
)
|
|
} else {
|
|
toast.success('Sprint aangemaakt')
|
|
}
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
const storyLabel = counts.hasUnknownTotal
|
|
? `${counts.storyCount}+`
|
|
: counts.storyCount
|
|
const pbiSuffix = counts.pbiCount === 1 ? '' : "'s"
|
|
|
|
return (
|
|
<div
|
|
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
|
|
{...debugProps('sprint-definition-banner')}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-sm font-medium shrink-0">
|
|
Sprint definiëren —
|
|
</span>
|
|
<span className="text-sm truncate" title={draft.goal}>
|
|
{draft.goal}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs opacity-80 mt-0.5">
|
|
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
|
|
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={handleCancel}
|
|
disabled={isPending}
|
|
data-debug-id="sprint-definition-banner__cancel"
|
|
>
|
|
Annuleren
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handleCreate}
|
|
disabled={isPending || counts.pbiCount === 0}
|
|
data-debug-id="sprint-definition-banner__create"
|
|
>
|
|
Sprint aanmaken
|
|
</Button>
|
|
</div>
|
|
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
|
|
<AlertDialogContent size="sm">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Je conceptselectie gaat verloren. Het sprint-doel en de
|
|
gemarkeerde PBI/stories worden verwijderd.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
|
|
Doorgaan
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
onClick={confirmCancelAction}
|
|
>
|
|
Ja, annuleren
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
)
|
|
}
|