feat(PBI-63): meerdere sprints per product + EXCLUDED + sprint-switcher (#161)

- Sprint lifecycle: ACTIVE→OPEN, COMPLETED→CLOSED, +ARCHIVED (FAILED behouden)
- TaskStatus: +EXCLUDED (overgeslagen door agent-loop via bestaande TO_DO filter)
- Cookie-gebaseerde actieve sprint per product (lib/active-sprint.ts)
- Route splitsen: /products/[id]/sprint/[sprintId] + /sprint redirect-page
- NavBar: gestapelde product/sprint dropdowns + BUILDING-badge derivatie
- Backlog selectie-modus + nieuwe-sprint-dialog (createSprintWithPbisAction)
- Migratie 20260507210000_sprint_lifecycle: ALTER TYPE RENAME (geen data-rewrite)
- Version bump 1.0.0 → 1.2.0

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-08 00:15:04 +02:00 committed by GitHub
parent d68aa1e5e6
commit 4a9db57e94
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 966 additions and 290 deletions

View file

@ -21,6 +21,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
@ -33,6 +34,7 @@ import { cn } from '@/lib/utils'
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel'
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
@ -124,19 +126,26 @@ function SortablePbiRow({
pbi,
isSelected,
isDemo,
selectionMode,
isChecked,
onSelect,
onToggleCheck,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
selectionMode: boolean
isChecked: boolean
onSelect: () => void
onToggleCheck: () => void
onEdit: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pbi.id,
disabled: selectionMode,
})
const style = {
@ -144,6 +153,37 @@ function SortablePbiRow({
transition,
}
if (selectionMode) {
return (
<BacklogCard
ref={setNodeRef}
style={style}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isChecked}
role="button"
tabIndex={0}
aria-pressed={isChecked}
onClick={onToggleCheck}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<div
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
aria-hidden="true"
>
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
</div>
}
/>
)
}
return (
<BacklogCard
ref={setNodeRef}
@ -207,8 +247,25 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
const [prefsLoaded, setPrefsLoaded] = useState(false)
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [newSprintOpen, setNewSprintOpen] = useState(false)
const [, startTransition] = useTransition()
function exitSelection() {
setSelectionMode(false)
setSelectedIds(new Set())
}
function toggleCheck(id: string) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// Load persisted preferences once after mount (client-only).
// setState calls here are intentional: hydrating from localStorage on first paint.
useEffect(() => {
@ -452,8 +509,23 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
<DemoTooltip show={isDemo}>
<Button
size="sm"
variant={selectionMode ? 'default' : 'outline'}
className="h-7 text-xs"
disabled={isDemo}
onClick={() => {
if (isDemo) return
if (selectionMode) exitSelection()
else setSelectionMode(true)
}}
>
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo || selectionMode}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
@ -486,7 +558,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
selectionMode={selectionMode}
isChecked={selectedIds.has(pbi.id)}
onSelect={() => selectPbi(pbi.id)}
onToggleCheck={() => toggleCheck(pbi.id)}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)}
/>
@ -507,11 +582,53 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
)}
</div>
{selectionMode && (
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
<span className="text-sm text-foreground">
{selectedIds.size} geselecteerd
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={exitSelection}
>
Annuleer
</Button>
<Button
size="sm"
className="h-7 text-xs"
disabled={selectedIds.size === 0}
onClick={() => setNewSprintOpen(true)}
>
Nieuwe sprint
</Button>
</div>
</div>
)}
<PbiDialog
state={dialogState}
onClose={() => setDialogState(null)}
isDemo={isDemo}
/>
<NewSprintDialog
open={newSprintOpen}
productId={productId}
pbiIds={Array.from(selectedIds)}
onOpenChange={(open) => {
setNewSprintOpen(open)
if (!open) {
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
}
}}
onCreated={() => {
setNewSprintOpen(false)
exitSelection()
}}
/>
</div>
)
}