Scrum4Me/components/backlog/save-sprint-button.tsx
Madhura68 117616f28b feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.

- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
  stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
  toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
  draft via upsertStoryOverride in A′ of pending buffer via
  toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
  zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
  enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
  → applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
  en SaveSprintButton.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:07:21 +02:00

89 lines
2.7 KiB
TypeScript

'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import {
selectIsDirty,
selectPendingCount,
} from '@/stores/product-workspace/selectors'
import { commitSprintMembershipAction } from '@/actions/sprints'
interface SaveSprintButtonProps {
activeSprintId: string
}
/**
* PBI-79 / ST-1338 / T-940: 'Sprint opslaan'-knop voor state B.
* Altijd zichtbaar zolang er een actieve sprint is. Disabled bij clean,
* enabled met teller bij dirty. Commit gebeurt via
* commitSprintMembershipAction; client patcht gericht via
* applyMembershipCommitResult. Geen router.refresh.
*/
export function SaveSprintButton({ activeSprintId }: SaveSprintButtonProps) {
const router = useRouter()
const isDirty = useProductWorkspaceStore(selectIsDirty)
const count = useProductWorkspaceStore(selectPendingCount)
const adds = useProductWorkspaceStore((s) => s.sprintMembership.pending.adds)
const removes = useProductWorkspaceStore(
(s) => s.sprintMembership.pending.removes,
)
const applyMembershipCommitResult = useProductWorkspaceStore(
(s) => s.applyMembershipCommitResult,
)
const [isPending, startTransition] = useTransition()
function handleSave() {
startTransition(async () => {
const result = await commitSprintMembershipAction({
activeSprintId,
adds: [...adds],
removes: [...removes],
})
if ('error' in result) {
toast.error(result.error)
return
}
applyMembershipCommitResult({
activeSprintId,
addedStoryIds: adds.filter((id) =>
result.affectedStoryIds.includes(id),
),
removedStoryIds: removes.filter((id) =>
result.affectedStoryIds.includes(id),
),
})
const skipped =
result.conflicts.notEligible.length +
result.conflicts.alreadyRemoved.length
if (skipped > 0) {
toast.warning(
`${skipped} wijziging${skipped === 1 ? '' : 'en'} overgeslagen — story al in andere sprint of inmiddels verwijderd.`,
)
} else {
toast.success('Sprint opgeslagen')
}
// Gericht patchen voldoende voor lokale UI; refresh haalt server-side
// counts opnieuw op zodat tri-state in volgende renders klopt.
router.refresh()
})
}
return (
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={!isDirty || isPending}
data-debug-id="save-sprint-button"
>
{isPending
? 'Opslaan…'
: isDirty
? `Sprint opslaan (${count})`
: 'Sprint opslaan'}
</Button>
)
}