Scrum4Me/components/backlog/sprint-definition-banner.tsx
Madhura68 d21011cdfa feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
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>
2026-05-11 16:58:15 +02:00

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>
)
}