Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.
- lib/user-settings.ts: layout krijgt nullable activePbis +
activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
doet de server-side auto-select-resolutie (single PBI → single story)
en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
nieuwe action aan en synchroniseert de workspace-store gelijk zodat
de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
effect dat user-settings.activePbis/activeStories naar workspace-store
spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
binnen BacklogHydrationWrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
206 lines
6.7 KiB
TypeScript
206 lines
6.7 KiB
TypeScript
'use client'
|
|
|
|
import { usePathname, useRouter } from 'next/navigation'
|
|
import { useState, useTransition } from 'react'
|
|
import { Check, ChevronDown } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
clearActiveSprintAction,
|
|
switchActiveSprintAction,
|
|
} from '@/actions/active-sprint'
|
|
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
|
import type { SprintStatusApi } from '@/lib/task-status'
|
|
import { debugProps } from '@/lib/debug'
|
|
|
|
type SprintItem = { id: string; code: string; sprint_goal: string; status: SprintStatusApi }
|
|
|
|
interface SprintSwitcherProps {
|
|
productId: string
|
|
sprints: SprintItem[]
|
|
activeSprint: SprintItem | null
|
|
buildingSprintIds: string[]
|
|
}
|
|
|
|
const SPRINT_STATUS_LABEL: Record<SprintStatusApi, string> = {
|
|
open: 'Open',
|
|
closed: 'Gesloten',
|
|
archived: 'Gearchiveerd',
|
|
failed: 'Mislukt',
|
|
}
|
|
|
|
export function SprintSwitcher({
|
|
productId,
|
|
sprints,
|
|
activeSprint,
|
|
buildingSprintIds,
|
|
}: SprintSwitcherProps) {
|
|
const pathname = usePathname()
|
|
const router = useRouter()
|
|
const [isPending, startTransition] = useTransition()
|
|
const [showClosed, setShowClosed] = useState(false)
|
|
const buildingSet = new Set(buildingSprintIds)
|
|
|
|
const visibleSprints = sprints.filter(s => {
|
|
if (showClosed) return true
|
|
if (s.id === activeSprint?.id) return true
|
|
return s.status === 'open'
|
|
})
|
|
|
|
function handleSwitchSprint(sprintId: string) {
|
|
if (sprintId === activeSprint?.id) return
|
|
startTransition(async () => {
|
|
const result = await switchActiveSprintAction(productId, sprintId)
|
|
if ('error' in result) {
|
|
toast.error(
|
|
typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
|
|
)
|
|
return
|
|
}
|
|
// Synchroniseer de client-side workspace-store met de auto-select die
|
|
// server-side is bepaald — voorkomt korte flash van vorige selectie
|
|
// voordat router.refresh de SSR-render binnenhaalt.
|
|
const store = useProductWorkspaceStore.getState()
|
|
if (result.pbiId) {
|
|
store.setActivePbi(result.pbiId)
|
|
if (result.storyId) {
|
|
store.setActiveStory(result.storyId)
|
|
}
|
|
} else {
|
|
store.setActivePbi(null)
|
|
}
|
|
if (pathname.includes('/sprint')) {
|
|
router.push(`/products/${productId}/sprint/${sprintId}`)
|
|
} else {
|
|
router.refresh()
|
|
}
|
|
})
|
|
}
|
|
|
|
function handleClearActiveSprint() {
|
|
if (!activeSprint) return
|
|
startTransition(async () => {
|
|
const result = await clearActiveSprintAction(productId)
|
|
if (result?.error) {
|
|
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
|
|
return
|
|
}
|
|
if (pathname.includes('/sprint')) {
|
|
router.push(`/products/${productId}`)
|
|
} else {
|
|
router.refresh()
|
|
}
|
|
})
|
|
}
|
|
|
|
if (sprints.length === 0) {
|
|
return (
|
|
<span {...debugProps('sprint-switcher')}>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
className="text-sm text-muted-foreground/50 px-2 cursor-not-allowed select-none"
|
|
aria-disabled="true"
|
|
>
|
|
Geen sprints
|
|
</TooltipTrigger>
|
|
<TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<span {...debugProps('sprint-switcher')}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
disabled={isPending}
|
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none"
|
|
>
|
|
<span className="truncate max-w-[160px]">
|
|
{activeSprint ? activeSprint.code : 'Selecteer sprint'}
|
|
</span>
|
|
{activeSprint && (
|
|
<span
|
|
className={cn(
|
|
'text-sm',
|
|
buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]}
|
|
</span>
|
|
)}
|
|
<ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="center" className="w-80">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault()
|
|
setShowClosed(v => !v)
|
|
}}
|
|
className="flex items-center gap-2 w-full px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-container rounded-md"
|
|
>
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center justify-center w-3.5 h-3.5 rounded border',
|
|
showClosed ? 'bg-primary border-primary text-primary-foreground' : 'border-border',
|
|
)}
|
|
>
|
|
{showClosed && <Check className="w-3 h-3" />}
|
|
</span>
|
|
Toon afgeronde sprints
|
|
</button>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={handleClearActiveSprint}
|
|
disabled={!activeSprint || isPending}
|
|
className={cn(
|
|
'italic text-muted-foreground',
|
|
!activeSprint && 'opacity-50 cursor-not-allowed',
|
|
)}
|
|
>
|
|
— Geen actieve sprint —
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
{visibleSprints.length === 0 ? (
|
|
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
|
|
Geen open sprints
|
|
</div>
|
|
) : (
|
|
visibleSprints.map(s => (
|
|
<DropdownMenuItem
|
|
key={s.id}
|
|
onClick={() => handleSwitchSprint(s.id)}
|
|
className={cn(
|
|
'flex items-center gap-2',
|
|
s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium',
|
|
)}
|
|
>
|
|
<span className="text-sm font-medium shrink-0">{s.code}</span>
|
|
<span className="text-sm truncate flex-1">{s.sprint_goal}</span>
|
|
<span
|
|
className={cn(
|
|
'text-sm shrink-0',
|
|
buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground',
|
|
)}
|
|
>
|
|
{buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]}
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</span>
|
|
)
|
|
}
|