Scrum4Me/components/backlog/story-panel.tsx
janpeter visser 4dd62c199c feat: ST-201-ST-210 M2 stories, drag-and-drop en Zustand stores
- usePlannerStore met pbiOrder/storyOrder init/reorder/rollback (ST-201)
- useSelectionStore uitgebreid met selectedStoryId en clearSelection (ST-202)
- PBI drag-and-drop binnen prioriteitsgroep via dnd-kit (ST-203)
- PBI slepen over prioriteitsgrens wijzigt priority (ST-204)
- Stories als blokken met prioriteit- en statusbadge (ST-205/ST-206)
- Story drag-and-drop horizontaal binnen en tussen groepen (ST-207)
- Story detail slide-over met bewerkformulier (ST-208)
- Story verwijderen met bevestigingsstap (ST-209)
- Filter op status en prioriteit in rechterpaneel (ST-210)
- Fix: infinite loop in useEffect door stabiele string dependency

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:46:18 +02:00

516 lines
18 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, useEffect, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { createStoryAction, updateStoryAction, deleteStoryAction, reorderStoriesAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
const PRIORITY_COLORS: Record<number, string> = {
1: 'bg-priority-critical/15 text-priority-critical border-priority-critical/30',
2: 'bg-priority-high/15 text-priority-high border-priority-high/30',
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/30',
4: 'bg-priority-low/15 text-priority-low border-priority-low/30',
}
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
title: string
description: string | null
acceptance_criteria: string | null
priority: number
status: string
pbi_id: string
}
interface StoryPanelProps {
productId: string
storiesByPbi: Record<string, Story[]>
isDemo: boolean
}
// --- Sortable story block ---
function SortableStoryBlock({
story,
onClick,
}: {
story: Story
onClick: () => 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 (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={onClick}
title={story.title}
className="w-28 shrink-0 bg-surface-container-low border border-border rounded-lg p-2 cursor-pointer hover:border-primary transition-colors space-y-1.5 select-none"
>
<p className="text-xs font-medium text-foreground line-clamp-3 min-h-[3rem]">
{story.title}
</p>
<div className="flex flex-col gap-1">
<Badge className={cn('text-[10px] px-1.5 py-0 border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
</Badge>
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status}
</Badge>
</div>
</div>
)
}
// --- Story detail slide-over ---
function StoryDetailSheet({
story,
productId,
pbiId,
onClose,
isDemo,
}: {
story: Story
productId: string
pbiId: string
onClose: () => void
isDemo: boolean
}) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [isDeleting, startDeleteTransition] = useTransition()
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateStoryAction(_prev, fd)
if (result?.success) onClose()
return result
},
undefined
)
function handleDelete() {
startDeleteTransition(async () => {
await deleteStoryAction(story.id)
onClose()
})
}
const fieldError = (field: string) => {
const err = state?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
return (
<Sheet open onOpenChange={(open) => { if (!open) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-5 pt-5 pb-4 border-b border-border">
<SheetTitle>{story.title}</SheetTitle>
<div className="flex gap-2 mt-1">
<Badge className={cn('text-xs border', PRIORITY_COLORS[story.priority])}>
{PRIORITY_LABELS[story.priority]}
</Badge>
<Badge className={cn('text-xs border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status]}
</Badge>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
{!isDemo ? (
<form action={formAction} className="p-5 space-y-4">
<input type="hidden" name="id" value={story.id} />
<input type="hidden" name="priority" value={story.priority} />
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label>
<Input name="title" defaultValue={story.title} required className={fieldError('title') ? 'border-error' : ''} />
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Omschrijving</label>
<Textarea name="description" rows={4} defaultValue={story.description ?? ''} placeholder="Als… wil ik… zodat…" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Acceptatiecriteria</label>
<Textarea name="acceptance_criteria" rows={4} defaultValue={story.acceptance_criteria ?? ''} placeholder="- Gegeven… Als… Dan…" />
</div>
{typeof state?.error === 'string' && (
<p className="text-xs text-error">{state.error}</p>
)}
<div className="flex gap-2 pt-2">
<SaveButton />
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
</div>
</form>
) : (
<div className="p-5 space-y-4">
{story.description && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p>
<p className="text-sm">{story.description}</p>
</div>
)}
{story.acceptance_criteria && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Acceptatiecriteria</p>
<p className="text-sm whitespace-pre-line">{story.acceptance_criteria}</p>
</div>
)}
</div>
)}
</div>
{!isDemo && (
<div className="border-t border-border p-4">
{confirmDelete ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex-1">Weet je het zeker? Taken worden ook verwijderd.</span>
<Button variant="destructive" size="sm" disabled={isDeleting} onClick={handleDelete}>
{isDeleting ? 'Bezig…' : 'Verwijderen'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmDelete(false)}>Annuleren</Button>
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-error hover:bg-error/10"
onClick={() => setConfirmDelete(true)}
>
Story verwijderen
</Button>
)}
</div>
)}
</SheetContent>
</Sheet>
)
}
function SaveButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Opslaan…' : 'Opslaan'}
</Button>
)
}
// --- Inline create form ---
function CreateStoryForm({
productId,
pbiId,
priority,
onDone,
}: {
productId: string
pbiId: string
priority: number
onDone: () => void
}) {
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createStoryAction(_prev, fd)
if (result?.success) onDone()
return result
},
undefined
)
return (
<form action={formAction} className="flex gap-2 items-center mt-2">
<input type="hidden" name="pbiId" value={pbiId} />
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="priority" value={priority} />
<Input name="title" autoFocus placeholder="Story titel…" className="h-7 text-sm flex-1" required />
<CreateStorySubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={onDone}>×</Button>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
</form>
)
}
function CreateStorySubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" className="h-7" disabled={pending}>
{pending ? '…' : 'Toevoegen'}
</Button>
)
}
// --- Main component ---
export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps) {
const { selectedPbiId } = useSelectionStore()
const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore()
const [filterStatus, setFilterStatus] = useState<string | null>(null)
const [filterPriority, setFilterPriority] = useState<number | null>(null)
const [creatingPriority, setCreatingPriority] = useState<number | null>(null)
const [openStory, setOpenStory] = useState<Story | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : []
// Sync into store — use stable string dep to avoid infinite loop
const storyIdKey = rawStories.map(s => s.id).join(',')
useEffect(() => {
if (selectedPbiId) {
initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : [])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedPbiId, storyIdKey])
const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s]))
const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id)
const orderedStories = order.map(id => storyMap[id]).filter(Boolean)
const filtered = orderedStories
.filter(s => !filterStatus || s.status === filterStatus)
.filter(s => !filterPriority || s.priority === filterPriority)
const grouped = [1, 2, 3, 4].reduce<Record<number, Story[]>>((acc, p) => {
acc[p] = filtered.filter(s => s.priority === p)
return acc
}, {} as Record<number, Story[]>)
const visiblePriorities = [1, 2, 3, 4].filter(
p => grouped[p].length > 0 || creatingPriority === p
)
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
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 prevOrder = [...order]
const oldIndex = order.indexOf(active.id as string)
const newIndex = order.indexOf(over.id as string)
const newOrder = arrayMove([...order], oldIndex, newIndex)
reorderStories(selectedPbiId, newOrder)
const priorityChanged = activeStory.priority !== overStory.priority
startTransition(async () => {
const result = await reorderStoriesAction(
selectedPbiId,
productId,
newOrder,
priorityChanged ? overStory.priority : undefined
)
if (!result.success) {
rollbackStories(selectedPbiId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
})
}
const hasActiveFilters = filterStatus !== null || filterPriority !== null
return (
<div className="flex flex-col h-full">
<PanelNavBar
title="Stories"
actions={
<>
{hasActiveFilters && (
<button onClick={() => { setFilterStatus(null); setFilterPriority(null) }} className="text-xs text-primary hover:underline">
Filter wissen ×
</button>
)}
<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 && !isDemo && (
<Button
size="sm"
className="h-7 text-xs"
onClick={() => setCreatingPriority(creatingPriority ? null : 2)}
>
+ Story
</Button>
)}
</>
}
/>
<div className="flex-1 overflow-y-auto p-4">
{selectedPbiId === null ? (
<p className="text-sm text-muted-foreground text-center mt-8">
Selecteer een PBI om de stories te bekijken.
</p>
) : rawStories.length === 0 && creatingPriority === null ? (
<div className="text-center mt-8 space-y-3">
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
{!isDemo && (
<Button size="sm" variant="outline" onClick={() => setCreatingPriority(2)}>
Maak je eerste story aan
</Button>
)}
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-4">
{visiblePriorities.map(priority => (
<div key={priority}>
<div className="flex items-center gap-2 mb-2">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]}
</span>
<div className="flex-1 h-px bg-border" />
{!isDemo && (
<button
onClick={() => setCreatingPriority(priority)}
className="text-xs text-muted-foreground hover:text-foreground"
>
+
</button>
)}
</div>
<SortableContext
items={grouped[priority].map(s => s.id)}
strategy={horizontalListSortingStrategy}
>
<div className="flex flex-wrap gap-2">
{grouped[priority].map(story => (
<SortableStoryBlock
key={story.id}
story={story}
onClick={() => setOpenStory(story)}
/>
))}
</div>
</SortableContext>
{creatingPriority === priority && selectedPbiId && (
<CreateStoryForm
productId={productId}
pbiId={selectedPbiId}
priority={priority}
onDone={() => setCreatingPriority(null)}
/>
)}
</div>
))}
{creatingPriority !== null && !visiblePriorities.includes(creatingPriority) && selectedPbiId && (
<div>
<div className="flex items-center gap-2 mb-2">
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[creatingPriority])}>
{PRIORITY_LABELS[creatingPriority]}
</span>
<div className="flex-1 h-px bg-border" />
</div>
<CreateStoryForm
productId={productId}
pbiId={selectedPbiId}
priority={creatingPriority}
onDone={() => setCreatingPriority(null)}
/>
</div>
)}
</div>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<div className="w-28 bg-surface-container-low border border-primary rounded-lg p-2 shadow-lg opacity-90">
<p className="text-xs font-medium line-clamp-3">{storyMap[activeDragId].title}</p>
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
{openStory && selectedPbiId && (
<StoryDetailSheet
story={openStory}
productId={productId}
pbiId={selectedPbiId}
isDemo={isDemo}
onClose={() => setOpenStory(null)}
/>
)}
</div>
)
}