Scrum4Me/components/backlog/story-panel.tsx
janpeter visser d11b114fc1 feat: ST-601-ST-612 M6 polish, beveiliging en launch-ready
- ST-601/602: loading skeletons en error boundary
- ST-603: Sonner toasts op alle CRUD-operaties
- ST-604: DemoTooltip op uitgeschakelde knoppen
- ST-605: KeyboardSensor dnd-kit, Escape sluit modals
- ST-606: min-width banner < 1024px
- ST-607: WCAG AA aria-labels en skip link
- ST-608: rate limiting login (10/min) en registratie (5/uur)
- ST-609: security integratietests cross-user toegang (7 tests)
- ST-610: GitHub Actions CI/CD workflow
- ST-611: README met quickstart, deployment en API-docs
- ST-612: Lars-flow acceptatiechecklist
- fix: settings toont gebruikersnaam i.p.v. interne id
- fix: seed idempotent, testdata altijd gekoppeld aan demo-gebruiker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:36:23 +02:00

539 lines
19 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,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from '@dnd-kit/core'
import {
SortableContext,
useSortable,
horizontalListSortingStrategy,
arrayMove,
sortableKeyboardCoordinates,
} 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, getStoryLogsAction } from '@/actions/stories'
import { StoryLog } from '@/components/shared/story-log'
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 [logs, setLogs] = useState<Awaited<ReturnType<typeof getStoryLogsAction>> | null>(null)
useEffect(() => {
getStoryLogsAction(story.id).then(setLogs)
}, [story.id])
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateStoryAction(_prev, fd)
if (result?.success) { toast.success('Story opgeslagen'); onClose() }
return result
},
undefined
)
function handleDelete() {
startDeleteTransition(async () => {
const result = await deleteStoryAction(story.id)
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
else toast.success('Story verwijderd')
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>
{/* Activity log */}
<div className="px-5 py-4 border-t border-border">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Activiteitenlog</p>
{logs && 'logs' in logs && logs.logs ? (
<StoryLog logs={logs.logs.map(l => ({ ...l, status: l.status ?? null, commit_hash: l.commit_hash ?? null, commit_message: l.commit_message ?? null }))} repoUrl={logs.repoUrl} />
) : (
<p className="text-xs text-muted-foreground">Laden</p>
)}
</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) { toast.success('Story aangemaakt'); 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 } }),
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 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>
)
}