feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw

UI-laag voor de sprint-definitie-flow (state A′).

Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
  'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
  'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
  'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
  verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.

Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
  hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
  upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
  upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
  stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
  NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.

Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 16:48:51 +02:00
parent 89c2356ff9
commit 947d970231
9 changed files with 708 additions and 100 deletions

View file

@ -26,8 +26,8 @@ const StoryOverridesSchema = z.object({
const DraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().datetime().optional(),
endAt: z.string().datetime().optional(),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
}).strict()

View file

@ -15,7 +15,8 @@ import { UrlTaskSync } from '@/components/backlog/url-task-sync'
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader'
import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton'
import { StartSprintButton } from '@/components/sprint/start-sprint-button'
import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger'
import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { EditProductButton } from '@/components/products/edit-product-button'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
@ -118,13 +119,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
{!isActiveProduct && (
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
)}
{hasOpenSprint ? (
{hasOpenSprint && (
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
Sprint actief
</Link>
) : (
!isDemo && <StartSprintButton productId={id} />
)}
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
{!isDemo && product.user_id === session.userId && (
<EditProductButton
product={{
@ -147,6 +147,9 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
</div>
</div>
{/* Sprint definition banner (state A) */}
<SprintDraftBanner productId={id} />
{/* Split pane */}
<div className="flex-1 overflow-hidden">
<BacklogHydrationWrapper

View file

@ -0,0 +1,203 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { debugProps } from '@/lib/debug'
interface NewSprintMetadataDialogProps {
open: boolean
productId: string
onOpenChange: (open: boolean) => void
}
function todayLocalDate(): string {
return new Date().toLocaleDateString('en-CA')
}
function plusWeeks(weeks: number): string {
const d = new Date()
d.setDate(d.getDate() + weeks * 7)
return d.toLocaleDateString('en-CA')
}
export function NewSprintMetadataDialog({
open,
productId,
onOpenChange,
}: NewSprintMetadataDialogProps) {
const [sprintGoal, setSprintGoal] = useState('')
const [startDate, setStartDate] = useState(todayLocalDate())
const [endDate, setEndDate] = useState(plusWeeks(2))
const [error, setError] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [isPending, startTransition] = useTransition()
const formRef = useRef<HTMLFormElement>(null)
const setPendingSprintDraft = useUserSettingsStore(
(s) => s.setPendingSprintDraft,
)
function reset() {
setSprintGoal('')
setStartDate(todayLocalDate())
setEndDate(plusWeeks(2))
setError(null)
setDirty(false)
}
const closeGuard = useDirtyCloseGuard(dirty, () => {
onOpenChange(false)
reset()
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const goal = sprintGoal.trim()
if (!goal) return
setError(null)
startTransition(async () => {
try {
await setPendingSprintDraft(productId, {
goal,
startAt: startDate || undefined,
endAt: endDate || undefined,
pbiIntent: {},
storyOverrides: {},
})
reset()
onOpenChange(false)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Onbekende fout bij opslaan'
setError(message)
toast.error(message)
}
})
}
const handleKeyDown = useDialogSubmitShortcut(() =>
formRef.current?.requestSubmit(),
)
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) closeGuard.attemptClose()
else onOpenChange(o)
}}
>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
{...debugProps(
'new-sprint-metadata-dialog',
'NewSprintMetadataDialog',
'components/backlog/new-sprint-metadata-dialog.tsx',
)}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
Nieuwe sprint
</DialogTitle>
<p className="text-xs text-muted-foreground mt-1">
Geef het sprint-doel en periode op. Je selecteert daarna PBI&apos;s
en stories via vinkjes in de backlog.
</p>
</div>
<form
ref={formRef}
id="new-sprint-metadata-form"
onSubmit={handleSubmit}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Sprint Goal <span className="text-error">*</span>
</label>
<Textarea
value={sprintGoal}
onChange={(e) => setSprintGoal(e.target.value)}
required
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Startdatum
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Einddatum
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{error && (
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
{error}
</div>
)}
</form>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<Button
type="submit"
form="new-sprint-metadata-form"
disabled={isPending || !sprintGoal.trim()}
data-debug-id="new-sprint-metadata-dialog__submit"
>
{isPending ? 'Opslaan…' : 'Verder'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -0,0 +1,46 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog'
interface NewSprintTriggerProps {
productId: string
isDemo: boolean
}
/**
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt dan
* staat de SprintDefinitionBanner zelf de afronding te regelen.
*/
export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) {
const [open, setOpen] = useState(false)
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
if (hasDraft) return null
return (
<>
<DemoTooltip show={isDemo}>
<Button
size="sm"
onClick={() => setOpen(true)}
disabled={isDemo}
data-debug-id="new-sprint-trigger"
>
Nieuwe sprint
</Button>
</DemoTooltip>
<NewSprintMetadataDialog
open={open}
productId={productId}
onOpenChange={setOpen}
/>
</>
)
}

View file

@ -21,7 +21,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import { CheckSquare, MinusSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@ -32,7 +32,11 @@ import {
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 {
selectPbiTriState,
selectVisiblePbis,
type PbiTriState,
} 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'
@ -41,7 +45,6 @@ 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'
@ -80,12 +83,20 @@ interface PbiListProps {
}
// --- Sortable PBI row ---
function TriStateIcon({ state }: { state: PbiTriState }) {
if (state === 'full')
return <CheckSquare size={18} className="text-primary" />
if (state === 'partial')
return <MinusSquare size={18} className="text-primary" />
return <Square size={18} />
}
function SortablePbiRow({
pbi,
isSelected,
isDemo,
selectionMode,
isChecked,
triState,
onSelect,
onToggleCheck,
onEdit,
@ -95,7 +106,7 @@ function SortablePbiRow({
isSelected: boolean
isDemo: boolean
selectionMode: boolean
isChecked: boolean
triState: PbiTriState
onSelect: () => void
onToggleCheck: () => void
onEdit: () => void
@ -112,6 +123,7 @@ function SortablePbiRow({
}
if (selectionMode) {
const isChecked = triState !== 'empty'
return (
<BacklogCard
ref={setNodeRef}
@ -135,7 +147,7 @@ function SortablePbiRow({
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} />}
<TriStateIcon state={triState} />
</div>
}
/>
@ -216,23 +228,21 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
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())
}
// PBI-79 / ST-1337: selectionMode is afgeleid van pendingSprintDraft.
// Wanneer er een draft voor dit product bestaat zit de pagina in state A
// en tonen we tri-state-vinkjes; klik = upsertPbiIntent.
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
const selectionMode = hasDraft
function toggleCheck(id: string) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function togglePbiInDraft(id: string, currentState: PbiTriState) {
// empty → all; partial → all; full → none.
const nextIntent = currentState === 'full' ? 'none' : 'all'
void upsertPbiIntent(productId, id, nextIntent)
}
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
@ -398,21 +408,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
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"
@ -445,15 +440,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
>
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
{filtered.map(pbi => (
<SortablePbiRow
<SortablePbiRowWithTriState
key={pbi.id}
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
selectionMode={selectionMode}
isChecked={selectedIds.has(pbi.id)}
productId={productId}
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
onToggleCheck={() => toggleCheck(pbi.id)}
onToggle={togglePbiInDraft}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)}
/>
@ -474,53 +469,72 @@ 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>
)
}
// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de
// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij
// re-rendert bij pbiIntent/storyOverrides-mutaties.
function SortablePbiRowWithTriState({
pbi,
isSelected,
isDemo,
selectionMode,
productId,
onSelect,
onToggle,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
selectionMode: boolean
productId: string
onSelect: () => void
onToggle: (id: string, currentState: PbiTriState) => void
onEdit: () => void
onDelete: () => void
}) {
// Tri-state uit pendingSprintDraft (state A) of pbiSummary (state B).
// Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent.
const triState = useUserSettingsStore((s) => {
const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId]
if (draft) {
const intent = draft.pbiIntent[pbi.id] ?? 'none'
const override = draft.storyOverrides[pbi.id]
if (intent === 'all') {
if (override?.remove.length) return 'partial'
return 'full'
}
if (override?.add.length) return 'partial'
return 'empty'
}
return null
})
const summaryTriState = useProductWorkspaceStore((s) =>
selectPbiTriState(s, pbi.id),
)
const effectiveTriState: PbiTriState =
triState ?? (selectionMode ? summaryTriState : 'empty')
return (
<SortablePbiRow
pbi={pbi}
isSelected={isSelected}
isDemo={isDemo}
selectionMode={selectionMode}
triState={effectiveTriState}
onSelect={onSelect}
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
onEdit={onEdit}
onDelete={onDelete}
/>
)
}

View file

@ -0,0 +1,176 @@
'use client'
import { useMemo, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type { PendingSprintDraft } from '@/lib/user-settings'
import { debugProps } from '@/lib/debug'
interface SprintDefinitionBannerProps {
productId: string
draft: PendingSprintDraft
}
type DraftCounts = {
pbiCount: number
storyCount: number
hasUnknownTotal: boolean
}
function computeCounts(
draft: PendingSprintDraft,
pbiSummary: Record<
string,
{ totalStoryCount: number; inActiveSprintStoryCount: number }
>,
): DraftCounts {
let pbiCount = 0
let storyCount = 0
let hasUnknownTotal = false
const seenPbis = new Set<string>()
for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) {
if (intent === 'all') {
seenPbis.add(pbiId)
const summary = pbiSummary[pbiId]
const override = draft.storyOverrides[pbiId]
if (!summary) {
hasUnknownTotal = true
continue
}
const removed = override?.remove.length ?? 0
storyCount += Math.max(0, summary.totalStoryCount - removed)
}
}
for (const [pbiId, override] of Object.entries(draft.storyOverrides)) {
if (override.add.length === 0) continue
seenPbis.add(pbiId)
storyCount += override.add.length
}
pbiCount = seenPbis.size
return { pbiCount, storyCount, hasUnknownTotal }
}
export function SprintDefinitionBanner({
productId,
draft,
}: SprintDefinitionBannerProps) {
const clearPendingSprintDraft = useUserSettingsStore(
(s) => s.clearPendingSprintDraft,
)
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
const [isPending, startTransition] = useTransition()
const [confirmCancel, setConfirmCancel] = useState(false)
const counts = useMemo(
() => computeCounts(draft, pbiSummary),
[draft, pbiSummary],
)
function handleCancel() {
setConfirmCancel(true)
}
function confirmCancelAction() {
setConfirmCancel(false)
startTransition(async () => {
try {
await clearPendingSprintDraft(productId)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Annuleren mislukt'
toast.error(message)
}
})
}
function handleCreate() {
// PBI-79 ST-1339 wires de createSprintWithSelectionAction in.
toast.info(
'Sprint aanmaken is nog niet aangesloten (wordt afgerond in ST-1339).',
)
}
const storyLabel = counts.hasUnknownTotal
? `${counts.storyCount}+`
: counts.storyCount
const pbiSuffix = counts.pbiCount === 1 ? '' : "'s"
return (
<div
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
{...debugProps('sprint-definition-banner')}
>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium shrink-0">
Sprint definiëren
</span>
<span className="text-sm truncate" title={draft.goal}>
{draft.goal}
</span>
</div>
<div className="text-xs opacity-80 mt-0.5">
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="ghost"
onClick={handleCancel}
disabled={isPending}
data-debug-id="sprint-definition-banner__cancel"
>
Annuleren
</Button>
<Button
type="button"
onClick={handleCreate}
disabled={isPending || counts.pbiCount === 0}
data-debug-id="sprint-definition-banner__create"
>
Sprint aanmaken
</Button>
</div>
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
<AlertDialogDescription>
Je conceptselectie gaat verloren. Het sprint-doel en de
gemarkeerde PBI/stories worden verwijderd.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
Doorgaan
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={confirmCancelAction}
>
Ja, annuleren
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,22 @@
'use client'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { SprintDefinitionBanner } from './sprint-definition-banner'
interface SprintDraftBannerProps {
productId: string
}
/**
* PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert
* als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via
* UserSettingsBridge dit component subscribt op die store en is daarmee
* automatisch reactief op draft-mutaties (set/clear).
*/
export function SprintDraftBanner({ productId }: SprintDraftBannerProps) {
const draft = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
if (!draft) return null
return <SprintDefinitionBanner productId={productId} draft={draft} />
}

View file

@ -21,6 +21,13 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -28,7 +35,10 @@ import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useShallow } from 'zustand/react/shallow'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
import {
selectStoriesForActivePbi,
selectStoryIsBlocked,
} from '@/stores/product-workspace/selectors'
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog'
@ -73,11 +83,17 @@ interface StoryPanelProps {
function SortableStoryBlock({
story,
isSelected,
cherrypick,
onSelect,
onEdit,
}: {
story: Story
isSelected: boolean
cherrypick: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
} | null
onSelect: () => void
onEdit: () => void
}) {
@ -109,21 +125,75 @@ function SortableStoryBlock({
</Badge>
}
actions={
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Story bewerken"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<div className="flex items-center gap-1">
{cherrypick && <StoryCherrypickButton {...cherrypick} />}
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Story bewerken"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
}
/>
)
}
function StoryCherrypickButton({
checked,
blocked,
onToggle,
}: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
}) {
const icon = checked ? (
<CheckSquare size={16} className="text-primary" />
) : (
<Square size={16} />
)
if (blocked) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
data-disabled="true"
aria-disabled="true"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground"
>
{icon}
</TooltipTrigger>
<TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return (
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
aria-pressed={checked}
aria-label={
checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen'
}
className={cn(
'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors',
'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</button>
)
}
// --- Main component ---
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
// (useShallow). DnD via applyOptimisticMutation('story-order').
@ -300,9 +370,10 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-3 gap-2">
{filtered.map(story => (
<SortableStoryBlock
<StoryBlockWithCherrypick
key={story.id}
story={story}
productId={productId}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
@ -332,3 +403,76 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
</div>
)
}
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
// crossSprintBlocks-mutaties.
function StoryBlockWithCherrypick({
story,
productId,
isSelected,
onSelect,
onEdit,
}: {
story: Story
productId: string
isSelected: boolean
onSelect: () => void
onEdit: () => void
}) {
const draft = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
const blocked = useProductWorkspaceStore((s) =>
selectStoryIsBlocked(s, story.id),
)
const cherrypick = draft
? (() => {
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
const override = draft.storyOverrides[story.pbi_id] ?? {
add: [],
remove: [],
}
const checked =
(intent === 'all' && !override.remove.includes(story.id)) ||
override.add.includes(story.id)
const onToggle = () => {
// Resolve next state to cancel-out cleanly.
if (intent === 'all') {
// Default = checked; toggling means "remove this story from sprint".
const isCurrentlyChecked = checked
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
isCurrentlyChecked ? 'remove' : 'clear',
)
} else {
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
checked ? 'clear' : 'add',
)
}
}
return {
checked,
blocked: blocked ? { sprintName: blocked.sprintName } : null,
onToggle,
}
})()
: null
return (
<SortableStoryBlock
story={story}
isSelected={isSelected}
cherrypick={cherrypick}
onSelect={onSelect}
onEdit={onEdit}
/>
)
}

View file

@ -57,8 +57,8 @@ const StoryOverrides = z.object({
const PendingSprintDraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().datetime().optional(),
endAt: z.string().datetime().optional(),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
pbiIntent: z.record(z.string(), PbiIntent).default({}),
storyOverrides: z.record(z.string(), StoryOverrides).default({}),
}).strict()