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 (