Scrum4Me/components/backlog/story-panel.tsx
Madhura68 117616f28b feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.

- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
  stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
  toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
  draft via upsertStoryOverride in A′ of pending buffer via
  toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
  zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
  enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
  → applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
  en SaveSprintButton.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 17:07:21 +02:00

500 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition } from 'react'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
rectSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} 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'
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,
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'
import { debugProps } from '@/lib/debug'
import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { cn } from '@/lib/utils'
type SortMode = 'priority' | 'code' | 'date'
const STATUS_COLORS: Record<string, string> = {
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
}
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
IN_SPRINT: 'In Sprint',
DONE: 'Klaar',
}
export interface Story {
id: string
code: string | null
title: string
description: string | null
acceptance_criteria: string | null
priority: number
sort_order: number
status: string
pbi_id: string
sprint_id: string | null
created_at: Date
}
interface StoryPanelProps {
productId: string
isDemo: boolean
activeSprintId?: string | null
}
// --- Sortable story block ---
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
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: story.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
}
return (
<BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={story.title}
code={story.code}
priority={story.priority}
isDragging={isDragging}
isSelected={isSelected}
onClick={onSelect}
badge={
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status}
</Badge>
}
actions={
<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').
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
const [filterStatus, setFilterStatus] = useState<string | null>(null)
const [filterPriority, setFilterPriority] = useState<number | null>(null)
const sortMode: SortMode = useUserSettingsStore(
(s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority',
)
const setPref = useUserSettingsStore((s) => s.setPref)
const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v)
const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
// rawStories komt al gesorteerd binnen via selectStoriesForActivePbi.
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const orderedStories = rawStories
const base = orderedStories
.filter(s => !filterStatus || s.status === filterStatus)
.filter(s => !filterPriority || s.priority === filterPriority)
const filtered = [...base].sort((a, b) => {
if (sortMode === 'code') {
return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
}
if (sortMode === 'date') {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
}
return a.priority !== b.priority ? a.priority - b.priority : 0
})
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 || !selectedPbiId) return
const activeStory = storyMap[active.id as string]
const overStory = storyMap[over.id as string]
if (!activeStory || !overStory) return
const store = useProductWorkspaceStore.getState()
const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])]
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)
const orderMutationId = store.applyOptimisticMutation({
kind: 'story-order',
pbiId: selectedPbiId,
prevStoryIds: prevOrder,
})
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi[selectedPbiId] = newOrder
})
const priorityChanged = activeStory.priority !== overStory.priority
let priorityMutationId: string | null = null
if (priorityChanged) {
priorityMutationId = store.applyOptimisticMutation({
kind: 'entity-patch',
entity: 'story',
id: active.id as string,
prev: store.entities.storiesById[active.id as string],
})
useProductWorkspaceStore.setState((s) => {
const story = s.entities.storiesById[active.id as string]
if (story) story.priority = overStory.priority
})
}
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
const st = useProductWorkspaceStore.getState()
if (result.success) {
if (priorityMutationId) st.settleMutation(priorityMutationId)
st.settleMutation(orderMutationId)
} else {
if (priorityMutationId) st.rollbackMutation(priorityMutationId)
st.rollbackMutation(orderMutationId)
toast.error('Volgorde opslaan mislukt')
}
})
}
const hasActiveFilters = filterStatus !== null || filterPriority !== null
return (
<div className="flex flex-col h-full" {...debugProps('story-panel', 'StoryPanel', 'components/backlog/story-panel.tsx')}>
<PanelNavBar
title="Stories"
actions={
<>
{hasActiveFilters && (
<button onClick={() => { setFilterStatus(null); setFilterPriority(null) }} className="text-xs text-primary hover:underline">
Filter wissen ×
</button>
)}
<Select value={sortMode} onValueChange={(v) => setSortMode(v as SortMode)}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="priority">Prioriteit</SelectItem>
<SelectItem value="code">Code</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
<Select
value={filterStatus ?? 'all'}
onValueChange={(v) => setFilterStatus(!v || v === 'all' ? null : v)}
>
<SelectTrigger className="h-7 w-24 text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle</SelectItem>
<SelectItem value="OPEN">Open</SelectItem>
<SelectItem value="IN_SPRINT">In Sprint</SelectItem>
<SelectItem value="DONE">Klaar</SelectItem>
</SelectContent>
</Select>
{selectedPbiId && (
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}
>
+ Story
</Button>
</DemoTooltip>
)}
</>
}
/>
<div className="flex-1 overflow-y-auto p-4" {...debugProps('story-panel__tasks')}>
{selectedPbiId === null ? (
<EmptyPanel message="Selecteer een PBI om de stories te bekijken." />
) : rawStories.length === 0 ? (
<EmptyPanel
message="Nog geen stories voor dit PBI."
action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }}
/>
) : (
<DndContext
id="story-panel"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-3 gap-2">
{filtered.map(story => (
<StoryBlockWithCherrypick
key={story.id}
story={story}
productId={productId}
activeSprintId={activeSprintId}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<BacklogCard
title={storyMap[activeDragId].title}
priority={storyMap[activeDragId].priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</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,
activeSprintId,
isSelected,
onSelect,
onEdit,
}: {
story: Story
productId: string
activeSprintId: string | null
isSelected: boolean
onSelect: () => void
onEdit: () => void
}) {
const draft = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
const toggleStorySprintMembership = useProductWorkspaceStore(
(s) => s.toggleStorySprintMembership,
)
const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending)
const blocked = useProductWorkspaceStore((s) =>
selectStoryIsBlocked(s, story.id),
)
let cherrypick: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
} | null = null
if (draft) {
// State A: muteer draft via per-PBI overrides.
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)
cherrypick = {
checked,
blocked: blocked ? { sprintName: blocked.sprintName } : null,
onToggle: () => {
if (intent === 'all') {
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
checked ? 'remove' : 'clear',
)
} else {
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
checked ? 'clear' : 'add',
)
}
},
}
} else if (activeSprintId) {
// State B: muteer pending buffer via toggleStorySprintMembership.
const inSprintDb = story.sprint_id === activeSprintId
const inAdds = pending.adds.includes(story.id)
const inRemoves = pending.removes.includes(story.id)
const checked = inAdds || (inSprintDb && !inRemoves)
cherrypick = {
checked,
blocked: blocked ? { sprintName: blocked.sprintName } : null,
onToggle: () => {
toggleStorySprintMembership(story.id, inSprintDb)
},
}
}
return (
<SortableStoryBlock
story={story}
isSelected={isSelected}
cherrypick={cherrypick}
onSelect={onSelect}
onEdit={onEdit}
/>
)
}