diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx
index 8ea05fe..4288882 100644
--- a/app/(app)/products/[id]/page.tsx
+++ b/app/(app)/products/[id]/page.tsx
@@ -17,6 +17,7 @@ import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
+import { SaveSprintButton } from '@/components/backlog/save-sprint-button'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { EditProductButton } from '@/components/products/edit-product-button'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
@@ -124,6 +125,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
Sprint actief →
)}
+ {activeSprintItem && !isDemo && (
+
+ )}
{!isDemo && }
{!isDemo && product.user_id === session.userId && (
,
,
(null)
const [, startTransition] = useTransition()
- // PBI-79 / ST-1337: selectionMode is afgeleid van pendingSprintDraft.
- // Wanneer er een draft voor dit product bestaat zit de pagina in state A′
- // en tonen we tri-state-vinkjes; klik = upsertPbiIntent.
+ // PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten:
+ // A′ (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent.
+ // B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer
+ // via toggleStorySprintMembership per child story (bulk).
+ // A (geen sprint, geen draft) → geen vinkjes.
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
- const selectionMode = hasDraft
+ const toggleStorySprintMembership = useProductWorkspaceStore(
+ (s) => s.toggleStorySprintMembership,
+ )
+ const stateBMode = !hasDraft && !!activeSprintId
+ const selectionMode = hasDraft || stateBMode
function togglePbiInDraft(id: string, currentState: PbiTriState) {
- // empty → all; partial → all; full → none.
- const nextIntent = currentState === 'full' ? 'none' : 'all'
- void upsertPbiIntent(productId, id, nextIntent)
+ if (hasDraft) {
+ // A′: empty/partial → all; full → none.
+ const nextIntent = currentState === 'full' ? 'none' : 'all'
+ void upsertPbiIntent(productId, id, nextIntent)
+ return
+ }
+ if (stateBMode && activeSprintId) {
+ // State B: bulk-toggle alle child-stories naar/uit de pending buffer.
+ const store = useProductWorkspaceStore.getState()
+ const storyIds = store.relations.storyIdsByPbi[id] ?? []
+ const goingFull = currentState !== 'full'
+ for (const storyId of storyIds) {
+ const story = store.entities.storiesById[storyId]
+ if (!story) continue
+ const blocked = store.sprintMembership.crossSprintBlocks[storyId]
+ if (blocked) continue
+ const inSprint = story.sprint_id === activeSprintId
+ if (goingFull && !inSprint) {
+ toggleStorySprintMembership(storyId, false)
+ }
+ if (!goingFull && inSprint) {
+ toggleStorySprintMembership(storyId, true)
+ }
+ }
+ }
}
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
diff --git a/components/backlog/save-sprint-button.tsx b/components/backlog/save-sprint-button.tsx
new file mode 100644
index 0000000..fe12538
--- /dev/null
+++ b/components/backlog/save-sprint-button.tsx
@@ -0,0 +1,89 @@
+'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 (
+
+ )
+}
diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx
index 853c8b4..fdfd186 100644
--- a/components/backlog/story-panel.tsx
+++ b/components/backlog/story-panel.tsx
@@ -77,6 +77,7 @@ export interface Story {
interface StoryPanelProps {
productId: string
isDemo: boolean
+ activeSprintId?: string | null
}
// --- Sortable story block ---
@@ -197,7 +198,7 @@ function StoryCherrypickButton({
// --- Main component ---
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
// (useShallow). DnD via applyOptimisticMutation('story-order').
-export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
+export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
@@ -374,6 +375,7 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
key={story.id}
story={story}
productId={productId}
+ activeSprintId={activeSprintId}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
@@ -410,12 +412,14 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
function StoryBlockWithCherrypick({
story,
productId,
+ activeSprintId,
isSelected,
onSelect,
onEdit,
}: {
story: Story
productId: string
+ activeSprintId: string | null
isSelected: boolean
onSelect: () => void
onEdit: () => void
@@ -424,47 +428,65 @@ function StoryBlockWithCherrypick({
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
+ const toggleStorySprintMembership = useProductWorkspaceStore(
+ (s) => s.toggleStorySprintMembership,
+ )
+ const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending)
const blocked = useProductWorkspaceStore((s) =>
selectStoryIsBlocked(s, story.id),
)
- const cherrypick = draft
- ? (() => {
- const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
- const override = draft.storyOverrides[story.pbi_id] ?? {
- add: [],
- remove: [],
+ let cherrypick: {
+ checked: boolean
+ blocked: { sprintName: string } | null
+ onToggle: () => void
+ } | null = null
+
+ if (draft) {
+ // State A′: muteer draft via per-PBI overrides.
+ const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
+ const override = draft.storyOverrides[story.pbi_id] ?? {
+ add: [],
+ remove: [],
+ }
+ const checked =
+ (intent === 'all' && !override.remove.includes(story.id)) ||
+ override.add.includes(story.id)
+ cherrypick = {
+ checked,
+ blocked: blocked ? { sprintName: blocked.sprintName } : null,
+ onToggle: () => {
+ if (intent === 'all') {
+ void upsertStoryOverride(
+ productId,
+ story.pbi_id,
+ story.id,
+ checked ? 'remove' : 'clear',
+ )
+ } else {
+ void upsertStoryOverride(
+ productId,
+ story.pbi_id,
+ story.id,
+ checked ? 'clear' : 'add',
+ )
}
- const checked =
- (intent === 'all' && !override.remove.includes(story.id)) ||
- override.add.includes(story.id)
- const onToggle = () => {
- // Resolve next state to cancel-out cleanly.
- if (intent === 'all') {
- // Default = checked; toggling means "remove this story from sprint".
- const isCurrentlyChecked = checked
- void upsertStoryOverride(
- productId,
- story.pbi_id,
- story.id,
- isCurrentlyChecked ? 'remove' : 'clear',
- )
- } else {
- void upsertStoryOverride(
- productId,
- story.pbi_id,
- story.id,
- checked ? 'clear' : 'add',
- )
- }
- }
- return {
- checked,
- blocked: blocked ? { sprintName: blocked.sprintName } : null,
- onToggle,
- }
- })()
- : null
+ },
+ }
+ } else if (activeSprintId) {
+ // State B: muteer pending buffer via toggleStorySprintMembership.
+ const inSprintDb = story.sprint_id === activeSprintId
+ const inAdds = pending.adds.includes(story.id)
+ const inRemoves = pending.removes.includes(story.id)
+ const checked = inAdds || (inSprintDb && !inRemoves)
+ cherrypick = {
+ checked,
+ blocked: blocked ? { sprintName: blocked.sprintName } : null,
+ onToggle: () => {
+ toggleStorySprintMembership(story.id, inSprintDb)
+ },
+ }
+ }
return (