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:
parent
d68aa1e5e6
commit
4a9db57e94
43 changed files with 966 additions and 290 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue