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>
This commit is contained in:
Janpeter Visser 2026-05-11 17:07:21 +02:00
parent 4c6e99958b
commit 117616f28b
4 changed files with 191 additions and 45 deletions

View file

@ -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
</Link>
)}
{activeSprintItem && !isDemo && (
<SaveSprintButton activeSprintId={activeSprintItem.id} />
)}
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
{!isDemo && product.user_id === session.userId && (
<EditProductButton
@ -171,11 +175,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
key="pbi"
productId={id}
isDemo={isDemo}
activeSprintId={activeSprintItem?.id ?? null}
/>,
<StoryPanel
key="story"
productId={id}
isDemo={isDemo}
activeSprintId={activeSprintItem?.id ?? null}
/>,
<TaskPanel
key="tasks"

View file

@ -80,6 +80,7 @@ interface Pbi {
interface PbiListProps {
productId: string
isDemo: boolean
activeSprintId?: string | null
}
// --- Sortable PBI row ---
@ -206,7 +207,7 @@ function SortablePbiRow({
// --- Main component ---
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
export function PbiList({ productId, isDemo }: PbiListProps) {
export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) {
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
// voorkomt re-render op ongerelateerde store-mutaties (G2).
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
@ -230,19 +231,47 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
const [activeDragId, setActiveDragId] = useState<string | null>(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).

View file

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

View file

@ -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 (
<SortableStoryBlock