Scrum4Me/components/backlog/story-panel.tsx
Madhura68 6671f3118c feat: change story grid to 3 columns
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 18:08:22 +02:00

312 lines
11 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 } 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 { 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 { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog'
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 PRIORITY_BORDER: Record<number, string> = {
1: 'border-l-4 border-l-priority-critical',
2: 'border-l-4 border-l-priority-high',
3: 'border-l-4 border-l-priority-medium',
4: 'border-l-4 border-l-priority-low',
}
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}
className={cn(
'bg-surface-container rounded border border-border px-3 py-2 select-none transition-colors cursor-pointer hover:bg-surface-container-high',
PRIORITY_BORDER[story.priority],
isDragging && 'opacity-40 shadow-lg',
)}
>
<p className="text-sm text-foreground leading-snug line-clamp-2">{story.title}</p>
<div className="flex items-center gap-1 mt-1.5">
<Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status}
</Badge>
</div>
</div>
)
}
// --- 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 [storyDialogState, setStoryDialogState] = useState<StoryDialogState | 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)
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={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 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 ? (
<div className="text-center mt-8 space-y-3">
<p className="text-sm text-muted-foreground">Nog geen stories voor dit PBI.</p>
{!isDemo && selectedPbiId && (
<Button size="sm" variant="outline" onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 })}>
Maak je eerste story aan
</Button>
)}
</div>
) : (
<DndContext
id="story-panel"
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 && selectedPbiId && (
<button
onClick={() => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: priority })}
className="text-xs text-muted-foreground hover:text-foreground"
>
+
</button>
)}
</div>
<SortableContext
items={grouped[priority].map(s => s.id)}
strategy={rectSortingStrategy}
>
<div className="grid grid-cols-3 gap-2">
{grouped[priority].map(story => (
<SortableStoryBlock
key={story.id}
story={story}
onClick={() => setStoryDialogState({ mode: 'edit', story, productId })}
/>
))}
</div>
</SortableContext>
</div>
))}
</div>
<DragOverlay>
{activeDragId && storyMap[activeDragId] && (
<div className={cn(
'bg-surface-container border border-primary rounded px-3 py-2 shadow-xl opacity-90',
PRIORITY_BORDER[storyMap[activeDragId].priority],
)}>
<p className="text-sm text-foreground leading-snug line-clamp-2">{storyMap[activeDragId].title}</p>
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
<StoryDialog
state={storyDialogState}
onClose={() => setStoryDialogState(null)}
isDemo={isDemo}
/>
</div>
)
}