* feat(PBI-76): one-shot localStorage→user-settings migration helper Reads all legacy keys (sprint_pb_*, pbi_*, story_sort, debug-mode, and dynamic *_filter_kind/*_filter_status for jobs columns) and returns a typed UserSettings patch plus the keys to clear. Idempotent via scrum4me:settings_migrated=v1 marker. Skips invalid values silently so existing corrupt entries do not block migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): bridge runs one-shot localStorage migration After hydrate, scans legacy localStorage keys via buildMigrationPatch and, if any data is found, pushes one bulk patch to the server, applies it locally, then removes the legacy keys. Demo accounts skip the migration entirely. Cancellable on unmount to avoid setState on unmounted component. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate sprint-backlog to user-settings store Replaces six useState+useEffect+localStorage flows with selectors from useUserSettingsStore. Defaults are applied at the selector level (filterStatus 'OPEN', sort 'code', etc) so the component matches its previous behaviour. The collapsed Set is derived from the persisted array, falling back to auto-collapse-DONE when no preference exists yet. setPref calls are fire-and-forget — the optimistic flow handles the local state update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate pbi-list to user-settings store Same pattern as sprint-backlog: replaces local useState + localStorage hydration/persist with selectors from useUserSettingsStore. filterPopoverOpen blijft lokaal — die was nooit gepersisteerd in pbi-list. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate story-panel sort to user-settings store Single pref (sortMode) — replaces sync localStorage useState initializer with a selector. Default 'priority' applied at the read site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate jobs-column to user-settings store Per-instance filter state (kinds + statuses) now lives under views.jobsColumns[storageKeyPrefix] in user-settings. Removes the local CSV-encoding helpers — store keeps arrays natively. A single persist() call writes both fields together so the two arrays cannot drift in optimistic mid-flight updates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-76): migrate debug-mode to user-settings store DebugToggle reads debugMode from user-settings.devTools and toggles via setPref. Removes the standalone stores/debug-store.ts (no consumers left). Body classlist update only fires after the store is hydrated to avoid a flash on initial paint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(PBI-76): remove unused readLocalStoragePref helper No consumers left after migrating sprint-backlog, pbi-list, story-panel, jobs-column, and debug-store to user-settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(PBI-76): mock user-settings action in backlog integration test PbiList now imports the user-settings store, which transitively loads actions/user-settings.ts → lib/prisma. The vitest jsdom environment has no DATABASE_URL, so we add a mock alongside the existing action mocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(docs): allow balanced parens in markdown link URLs Previously the link-checker regex stopped at the first ')', breaking on Next.js route-group paths like `app/(app)/...`. The new regex matches one level of balanced parens inside the URL. Caught by CI on PR #188 — pre-existing breakage from PBI-78 plan doc that was already merged on main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
526 lines
18 KiB
TypeScript
526 lines
18 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useTransition } from 'react'
|
||
import {
|
||
DndContext,
|
||
DragEndEvent,
|
||
DragOverlay,
|
||
DragStartEvent,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
closestCenter,
|
||
} from '@dnd-kit/core'
|
||
import {
|
||
SortableContext,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
arrayMove,
|
||
sortableKeyboardCoordinates,
|
||
} 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 {
|
||
BacklogFilterPopover,
|
||
PRIORITY_LABELS,
|
||
type SortDir,
|
||
} from '@/components/shared/backlog-filter-popover'
|
||
import { useShallow } from 'zustand/react/shallow'
|
||
import { useUserSettingsStore } from '@/stores/user-settings/store'
|
||
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
|
||
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
|
||
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
|
||
import { deletePbiAction } from '@/actions/pbis'
|
||
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
|
||
import { cn } from '@/lib/utils'
|
||
import { debugProps } from '@/lib/debug'
|
||
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'
|
||
import type { PbiStatusApi } from '@/lib/task-status'
|
||
|
||
type SortMode = 'priority' | 'code' | 'date'
|
||
|
||
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
|
||
{ value: 'priority', label: 'Prioriteit' },
|
||
{ value: 'code', label: 'Code' },
|
||
{ value: 'date', label: 'Datum' },
|
||
]
|
||
|
||
type PbiStatusFilter = PbiStatusApi | 'all'
|
||
|
||
const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [
|
||
{ value: 'all', label: 'Alle' },
|
||
{ value: 'ready', label: 'Klaar' },
|
||
{ value: 'blocked', label: 'Geblokkeerd' },
|
||
{ value: 'done', label: 'Afgerond' },
|
||
]
|
||
|
||
interface Pbi {
|
||
id: string
|
||
code: string | null
|
||
title: string
|
||
priority: number
|
||
description?: string | null
|
||
created_at: Date
|
||
status: PbiStatusApi
|
||
}
|
||
|
||
interface PbiListProps {
|
||
productId: string
|
||
isDemo: boolean
|
||
}
|
||
|
||
// --- Sortable PBI row ---
|
||
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 = {
|
||
transform: CSS.Transform.toString(transform),
|
||
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}
|
||
style={style}
|
||
{...attributes}
|
||
{...listeners}
|
||
title={pbi.title}
|
||
code={pbi.code}
|
||
priority={pbi.priority}
|
||
isSelected={isSelected}
|
||
isDragging={isDragging}
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-pressed={isSelected}
|
||
onClick={onSelect}
|
||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
|
||
badge={
|
||
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
|
||
{PBI_STATUS_LABELS[pbi.status]}
|
||
</Badge>
|
||
}
|
||
actions={
|
||
<div className="flex items-center gap-1">
|
||
<DemoTooltip show={isDemo}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); if (!isDemo) onEdit() }}
|
||
className="inline-flex items-center justify-center min-h-7 min-w-7 border border-border rounded text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||
aria-label="Bewerk PBI"
|
||
disabled={isDemo}
|
||
>
|
||
✎
|
||
</button>
|
||
</DemoTooltip>
|
||
<DemoTooltip show={isDemo}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); if (!isDemo) onDelete() }}
|
||
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-error text-base leading-none disabled:opacity-40 disabled:cursor-not-allowed"
|
||
aria-label="Verwijder PBI"
|
||
disabled={isDemo}
|
||
>
|
||
×
|
||
</button>
|
||
</DemoTooltip>
|
||
</div>
|
||
}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// --- 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) {
|
||
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
|
||
// voorkomt re-render op ongerelateerde store-mutaties (G2).
|
||
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
|
||
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
|
||
const prefs = useUserSettingsStore(
|
||
useShallow((s) => s.entities.settings.views?.pbiList ?? {}),
|
||
)
|
||
const setPref = useUserSettingsStore((s) => s.setPref)
|
||
const filterPriority = prefs.filterPriority ?? 'all'
|
||
const filterStatus: PbiStatusFilter = prefs.filterStatus ?? 'all'
|
||
const sortMode: SortMode = prefs.sort ?? 'priority'
|
||
const sortDir: SortDir = prefs.sortDir ?? 'asc'
|
||
const setFilterPriority = (v: number | 'all') =>
|
||
void setPref(['views', 'pbiList', 'filterPriority'], v)
|
||
const setFilterStatus = (v: PbiStatusFilter) =>
|
||
void setPref(['views', 'pbiList', 'filterStatus'], v)
|
||
const setSortMode = (v: SortMode) => void setPref(['views', 'pbiList', 'sort'], v)
|
||
const setSortDir = (v: SortDir) => void setPref(['views', 'pbiList', 'sortDir'], v)
|
||
const [filterPopoverOpen, setFilterPopoverOpen] = 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
|
||
})
|
||
}
|
||
|
||
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
|
||
// Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid.
|
||
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
|
||
const orderedPbis = pbis
|
||
|
||
const base = orderedPbis.filter(p => {
|
||
if (filterPriority !== 'all' && p.priority !== filterPriority) return false
|
||
if (filterStatus !== 'all' && p.status !== filterStatus) return false
|
||
return true
|
||
})
|
||
|
||
const activeFilterCount =
|
||
(filterPriority !== 'all' ? 1 : 0) +
|
||
(filterStatus !== 'all' ? 1 : 0) +
|
||
(sortMode !== 'priority' ? 1 : 0) +
|
||
(sortDir !== 'asc' ? 1 : 0)
|
||
|
||
const filtered = [...base].sort((a, b) => {
|
||
let cmp = 0
|
||
if (sortMode === 'code') {
|
||
cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
|
||
} else if (sortMode === 'date') {
|
||
cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||
} else {
|
||
cmp = a.priority !== b.priority ? a.priority - b.priority : 0
|
||
}
|
||
return sortDir === 'desc' ? -cmp : cmp
|
||
})
|
||
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||
)
|
||
|
||
function handleDragStart(event: DragStartEvent) {
|
||
setActiveDragId(event.active.id as string)
|
||
}
|
||
|
||
function handleDragEnd(event: DragEndEvent) {
|
||
setActiveDragId(null)
|
||
const { active, over } = event
|
||
if (!over || active.id === over.id) return
|
||
|
||
const activePbi = pbiMap[active.id as string]
|
||
const overPbi = pbiMap[over.id as string]
|
||
if (!activePbi || !overPbi) return
|
||
|
||
const store = useProductWorkspaceStore.getState()
|
||
const prevOrder = [...store.relations.pbiIds]
|
||
const oldIndex = prevOrder.indexOf(active.id as string)
|
||
const newIndex = prevOrder.indexOf(over.id as string)
|
||
if (oldIndex === -1 || newIndex === -1) return
|
||
const newOrder = arrayMove([...prevOrder], oldIndex, newIndex)
|
||
|
||
// Snapshot rollback-info en pas optimistisch toe.
|
||
const orderMutationId = store.applyOptimisticMutation({
|
||
kind: 'pbi-order',
|
||
prevPbiIds: prevOrder,
|
||
})
|
||
useProductWorkspaceStore.setState((s) => {
|
||
s.relations.pbiIds = newOrder
|
||
})
|
||
|
||
const priorityChanged = activePbi.priority !== overPbi.priority
|
||
let priorityMutationId: string | null = null
|
||
if (priorityChanged) {
|
||
priorityMutationId = store.applyOptimisticMutation({
|
||
kind: 'entity-patch',
|
||
entity: 'pbi',
|
||
id: active.id as string,
|
||
prev: store.entities.pbisById[active.id as string],
|
||
})
|
||
useProductWorkspaceStore.setState((s) => {
|
||
const pbi = s.entities.pbisById[active.id as string]
|
||
if (pbi) pbi.priority = overPbi.priority
|
||
})
|
||
}
|
||
|
||
startTransition(async () => {
|
||
const settle = () => {
|
||
const st = useProductWorkspaceStore.getState()
|
||
if (priorityMutationId) st.settleMutation(priorityMutationId)
|
||
st.settleMutation(orderMutationId)
|
||
}
|
||
const rollback = (msg: string) => {
|
||
const st = useProductWorkspaceStore.getState()
|
||
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
|
||
st.rollbackMutation(orderMutationId)
|
||
toast.error(msg)
|
||
}
|
||
|
||
if (priorityChanged) {
|
||
const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId)
|
||
if (result.success) settle()
|
||
else rollback('Prioriteit opslaan mislukt')
|
||
} else {
|
||
const result = await reorderPbisAction(productId, newOrder)
|
||
if (result.success) settle()
|
||
else rollback('Volgorde opslaan mislukt')
|
||
}
|
||
})
|
||
}
|
||
|
||
function handleDelete(id: string) {
|
||
startTransition(async () => {
|
||
await deletePbiAction(id)
|
||
if (selectedPbiId === id) {
|
||
useProductWorkspaceStore.getState().setActivePbi(null)
|
||
}
|
||
})
|
||
}
|
||
|
||
const activePbi = activeDragId ? pbiMap[activeDragId] : null
|
||
|
||
return (
|
||
<div className="flex flex-col h-full" {...debugProps('pbi-list', 'PbiList', 'components/backlog/pbi-list.tsx')}>
|
||
<div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-container-low shrink-0" {...debugProps('pbi-list__header')}>
|
||
{filterPriority !== 'all' && (
|
||
<button
|
||
onClick={() => setFilterPriority('all')}
|
||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||
aria-label="Wis prioriteitsfilter"
|
||
>
|
||
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
|
||
{PRIORITY_LABELS[filterPriority]}
|
||
</Badge>
|
||
<span>×</span>
|
||
</button>
|
||
)}
|
||
{filterStatus !== 'all' && (
|
||
<button
|
||
onClick={() => setFilterStatus('all')}
|
||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||
aria-label="Wis statusfilter"
|
||
>
|
||
<Badge className={cn('text-xs', PBI_STATUS_COLORS[filterStatus])}>
|
||
{PBI_STATUS_LABELS[filterStatus]}
|
||
</Badge>
|
||
<span>×</span>
|
||
</button>
|
||
)}
|
||
<BacklogFilterPopover
|
||
open={filterPopoverOpen}
|
||
onOpenChange={setFilterPopoverOpen}
|
||
filterPriority={filterPriority}
|
||
onFilterPriorityChange={setFilterPriority}
|
||
filterStatus={filterStatus}
|
||
onFilterStatusChange={setFilterStatus}
|
||
statusOptions={STATUS_OPTIONS}
|
||
sort={sortMode}
|
||
onSortChange={setSortMode}
|
||
sortDir={sortDir}
|
||
onSortDirChange={setSortDir}
|
||
sortOptions={SORT_OPTIONS}
|
||
activeFilterCount={activeFilterCount}
|
||
resetDisabled={activeFilterCount === 0}
|
||
onReset={() => {
|
||
setFilterPriority('all')
|
||
setFilterStatus('all')
|
||
setSortMode('priority')
|
||
setSortDir('asc')
|
||
}}
|
||
/>
|
||
<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
|
||
</Button>
|
||
</DemoTooltip>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{pbis.length === 0 ? (
|
||
<EmptyPanel
|
||
message="Nog geen PBI's aangemaakt."
|
||
action={{ label: 'Maak je eerste PBI aan', onClick: () => setDialogState({ mode: 'create', productId, defaultPriority: 2 }), disabled: isDemo }}
|
||
/>
|
||
) : (
|
||
<DndContext
|
||
id="pbi-list"
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragStart={handleDragStart}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={filtered.map(p => p.id)}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
|
||
{filtered.map(pbi => (
|
||
<SortablePbiRow
|
||
key={pbi.id}
|
||
pbi={pbi}
|
||
isSelected={selectedPbiId === pbi.id}
|
||
isDemo={isDemo}
|
||
selectionMode={selectionMode}
|
||
isChecked={selectedIds.has(pbi.id)}
|
||
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
|
||
onToggleCheck={() => toggleCheck(pbi.id)}
|
||
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
|
||
onDelete={() => handleDelete(pbi.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
|
||
<DragOverlay>
|
||
{activePbi && (
|
||
<BacklogCard
|
||
title={activePbi.title}
|
||
priority={activePbi.priority}
|
||
className="border-primary shadow-xl opacity-90"
|
||
/>
|
||
)}
|
||
</DragOverlay>
|
||
</DndContext>
|
||
)}
|
||
</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>
|
||
)
|
||
}
|