refactor: extract BacklogCard component, use for stories and PBIs

Single card component with priority border, selected state, badge
and actions slots. Replaces duplicate inline card markup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 18:13:03 +02:00
parent 6671f3118c
commit 34848588a8
3 changed files with 85 additions and 54 deletions

View file

@ -0,0 +1,49 @@
'use client'
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'
export 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',
}
interface BacklogCardProps extends React.HTMLAttributes<HTMLDivElement> {
title: string
priority: number
isSelected?: boolean
isDragging?: boolean
badge?: React.ReactNode
actions?: React.ReactNode
}
export const BacklogCard = forwardRef<HTMLDivElement, BacklogCardProps>(function BacklogCard(
{ title, priority, isSelected, isDragging, badge, actions, className, ...rest },
ref
) {
return (
<div
ref={ref}
className={cn(
'bg-surface-container rounded border border-border px-3 py-2 select-none transition-colors cursor-pointer',
PRIORITY_BORDER[priority],
isSelected
? 'bg-primary-container border-primary text-primary-container-foreground'
: 'hover:bg-surface-container-high',
isDragging && 'opacity-40 shadow-lg',
className,
)}
{...rest}
>
<p className="text-sm leading-snug line-clamp-2">{title}</p>
{(badge || actions) && (
<div className="flex items-center justify-between gap-1 mt-1.5">
<div className="flex items-center gap-1">{badge}</div>
{actions && <div className="flex items-center gap-1 shrink-0">{actions}</div>}
</div>
)}
</div>
)
})

View file

@ -31,6 +31,7 @@ import { deletePbiAction } from '@/actions/pbis'
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
const PRIORITY_LABELS: Record<number, string> = { const PRIORITY_LABELS: Record<number, string> = {
1: 'Kritiek', 1: 'Kritiek',
@ -82,37 +83,25 @@ function SortablePbiRow({
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
opacity: isDragging ? 0.4 : 1,
} }
return ( return (
<div <BacklogCard
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
{...attributes}
{...listeners}
title={pbi.title}
priority={pbi.priority}
isSelected={isSelected}
isDragging={isDragging}
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-selected={isSelected} aria-selected={isSelected}
onClick={onSelect} onClick={onSelect}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
className={cn( actions={!isDemo ? (
'group flex items-center justify-between px-4 py-2 cursor-pointer transition-colors hover:bg-surface-container focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset', <div className="flex items-center gap-1">
isSelected && 'bg-primary-container text-primary-container-foreground'
)}
>
{!isDemo && (
<span
{...attributes}
{...listeners}
aria-label="Versleep om te sorteren"
className="mr-2 text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 select-none"
onClick={(e) => e.stopPropagation()}
>
</span>
)}
<span className="text-sm truncate flex-1">{pbi.title}</span>
{!isDemo && (
<div className="flex items-center gap-1 ml-2 shrink-0">
<button <button
onClick={(e) => { e.stopPropagation(); onEdit() }} onClick={(e) => { e.stopPropagation(); onEdit() }}
className="border border-border rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors" className="border border-border rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
@ -122,14 +111,14 @@ function SortablePbiRow({
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete() }} onClick={(e) => { e.stopPropagation(); onDelete() }}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-error text-xs" className="text-muted-foreground hover:text-error text-xs"
aria-label="Verwijder PBI" aria-label="Verwijder PBI"
> >
× ×
</button> </button>
</div> </div>
)} ) : undefined}
</div> />
) )
} }
@ -286,10 +275,10 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="py-2"> <div className="p-3 space-y-4">
{visiblePriorities.map(priority => ( {visiblePriorities.map(priority => (
<div key={priority}> <div key={priority}>
<div className="flex items-center gap-2 px-4 py-1.5"> <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])}> <span className={cn('text-xs font-semibold px-2 py-0.5 rounded-full border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]} {PRIORITY_LABELS[priority]}
</span> </span>
@ -309,6 +298,7 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
items={grouped[priority].map(p => p.id)} items={grouped[priority].map(p => p.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="flex flex-col gap-2">
{grouped[priority].map(pbi => ( {grouped[priority].map(pbi => (
<SortablePbiRow <SortablePbiRow
key={pbi.id} key={pbi.id}
@ -320,17 +310,19 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
onDelete={() => handleDelete(pbi.id)} onDelete={() => handleDelete(pbi.id)}
/> />
))} ))}
</div>
</SortableContext> </SortableContext>
</div> </div>
))} ))}
</div> </div>
<DragOverlay> <DragOverlay>
{activePbi && ( {activePbi && (
<div className="bg-surface-container-low border border-primary rounded px-4 py-2 text-sm shadow-lg opacity-90"> <BacklogCard
{activePbi.title} title={activePbi.title}
</div> priority={activePbi.priority}
className="border-primary shadow-xl opacity-90"
/>
)} )}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>

View file

@ -29,6 +29,7 @@ import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store' import { usePlannerStore } from '@/stores/planner-store'
import { reorderStoriesAction } from '@/actions/stories' import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog' import { StoryDialog, type StoryDialogState } from './story-dialog'
import { BacklogCard, PRIORITY_BORDER } from './backlog-card'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' } const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
@ -38,12 +39,6 @@ const PRIORITY_COLORS: Record<number, string> = {
3: 'bg-priority-medium/15 text-priority-medium border-priority-medium/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', 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> = { const STATUS_COLORS: Record<string, string> = {
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30', 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', IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
@ -90,25 +85,21 @@ function SortableStoryBlock({
} }
return ( return (
<div <BacklogCard
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
{...attributes} {...attributes}
{...listeners} {...listeners}
title={story.title}
priority={story.priority}
isDragging={isDragging}
onClick={onClick} onClick={onClick}
className={cn( badge={
'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])}> <Badge className={cn('text-[10px] px-1.5 py-0 border', STATUS_COLORS[story.status])}>
{STATUS_LABELS[story.status] ?? story.status} {STATUS_LABELS[story.status] ?? story.status}
</Badge> </Badge>
</div> }
</div> />
) )
} }
@ -290,12 +281,11 @@ export function StoryPanel({ productId, storiesByPbi, isDemo }: StoryPanelProps)
<DragOverlay> <DragOverlay>
{activeDragId && storyMap[activeDragId] && ( {activeDragId && storyMap[activeDragId] && (
<div className={cn( <BacklogCard
'bg-surface-container border border-primary rounded px-3 py-2 shadow-xl opacity-90', title={storyMap[activeDragId].title}
PRIORITY_BORDER[storyMap[activeDragId].priority], priority={storyMap[activeDragId].priority}
)}> className="border-primary shadow-xl opacity-90"
<p className="text-sm text-foreground leading-snug line-clamp-2">{storyMap[activeDragId].title}</p> />
</div>
)} )}
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>