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>
89 lines
2.7 KiB
TypeScript
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>
|
|
)
|
|
}
|