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:
parent
4c6e99958b
commit
117616f28b
4 changed files with 191 additions and 45 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
89
components/backlog/save-sprint-button.tsx
Normal file
89
components/backlog/save-sprint-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue