Scrum4Me/components/backlog/pbi-list.tsx
Madhura68 5e0308d42e feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip
Convert all !isDemo && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
2026-04-29 18:27:39 +02:00

463 lines
15 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,
verticalListSortingStrategy,
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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store'
import { deletePbiAction } from '@/actions/pbis'
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
import { cn } from '@/lib/utils'
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
import type { PbiStatusApi } from '@/lib/task-status'
const PRIORITY_LABELS: Record<number, string> = {
1: 'Kritiek',
2: 'Hoog',
3: 'Gemiddeld',
4: 'Laag',
}
type SortMode = 'priority' | 'code' | 'date'
const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [
{ value: 'priority', label: 'Prioriteit' },
{ value: 'code', label: 'Code' },
{ value: 'date', label: 'Datum' },
]
const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 1, label: 'Kritiek' },
{ value: 2, label: 'Hoog' },
{ value: 3, label: 'Gemiddeld' },
{ value: 4, label: 'Laag' },
]
const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [
{ value: 'all', label: 'Alle' },
{ value: 'ready', label: 'Klaar' },
{ value: 'blocked', label: 'Geblokkeerd' },
{ value: 'done', label: 'Afgerond' },
]
function FilterPills<T extends string | number>({
label,
options,
value,
onChange,
}: {
label: string
options: Array<{ value: T; label: string }>
value: T
onChange: (v: T) => void
}) {
return (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">{label}</p>
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => onChange(opt.value)}
className={cn(
'text-xs px-2.5 py-1 rounded-full border transition-colors',
value === opt.value
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent border-border hover:bg-surface-container'
)}
>
{opt.label}
</button>
))}
</div>
</div>
)
}
interface Pbi {
id: string
code: string | null
title: string
priority: number
description?: string | null
created_at: Date
status: PbiStatusApi
}
interface PbiListProps {
productId: string
pbis: Pbi[]
isDemo: boolean
}
// --- Sortable PBI row ---
function SortablePbiRow({
pbi,
isSelected,
isDemo,
onSelect,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
onSelect: () => void
onEdit: () => void
onDelete: () => void
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: pbi.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<BacklogCard
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isSelected}
isDragging={isDragging}
role="button"
tabIndex={0}
aria-selected={isSelected}
onClick={onSelect}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<div className="flex items-center gap-1">
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) 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 disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk PBI"
disabled={isDemo}
>
</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) onDelete() }}
className="text-muted-foreground hover:text-error text-xs disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Verwijder PBI"
disabled={isDemo}
>
×
</button>
</DemoTooltip>
</div>
}
/>
)
}
// --- Main component ---
export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
const { selectedPbiId, selectPbi } = useSelectionStore()
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
const [filterPriority, setFilterPriority] = useState<number | 'all'>(() => {
if (typeof window === 'undefined') return 'all'
const saved = localStorage.getItem('scrum4me:pbi_filter_priority')
if (!saved || saved === 'all') return 'all'
const n = parseInt(saved, 10)
return Number.isInteger(n) && n >= 1 && n <= 4 ? n : 'all'
})
const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>(() => {
if (typeof window === 'undefined') return 'all'
const saved = localStorage.getItem('scrum4me:pbi_filter_status')
return saved === 'ready' || saved === 'blocked' || saved === 'done' ? saved : 'all'
})
const [sortMode, setSortMode] = useState<SortMode>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:pbi_sort') : null
return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority'
})
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
useEffect(() => { localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode])
useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority])
useEffect(() => { localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus])
// Sync server data into store — use stable string dep to avoid infinite loop
const pbiIdKey = pbis.map(p => p.id).join(',')
useEffect(() => {
initPbis(productId, pbiIdKey ? pbiIdKey.split(',') : [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [productId, pbiIdKey])
// Build ordered PBI list from store (or fall back to server order)
const order = pbiOrder[productId] ?? pbis.map(p => p.id)
const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p]))
// Apply priority overrides from store
const orderedPbis = order
.map(id => pbiMap[id])
.filter(Boolean)
.map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority }))
const base = orderedPbis.filter(p => {
if (filterPriority !== 'all' && p.priority !== filterPriority) return false
if (filterStatus !== 'all' && p.status !== filterStatus) return false
return true
})
const activeFilterCount =
(filterPriority !== 'all' ? 1 : 0) +
(filterStatus !== 'all' ? 1 : 0) +
(sortMode !== 'priority' ? 1 : 0)
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()
}
// priority: sort by priority asc, then drag-and-drop sort_order within group
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) return
const activePbi = pbiMap[active.id as string]
const overPbi = pbiMap[over.id as string]
if (!activePbi || !overPbi) 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)
// Optimistic update
reorderPbis(productId, newOrder)
const priorityChanged = activePbi.priority !== overPbi.priority
startTransition(async () => {
if (priorityChanged) {
updatePbiPriority(active.id as string, overPbi.priority)
const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId)
if (!result.success) {
rollbackPbis(productId, prevOrder)
toast.error('Prioriteit opslaan mislukt')
}
} else {
const result = await reorderPbisAction(productId, newOrder)
if (!result.success) {
rollbackPbis(productId, prevOrder)
toast.error('Volgorde opslaan mislukt')
}
}
})
}
function handleDelete(id: string) {
startTransition(async () => {
await deletePbiAction(id)
if (selectedPbiId === id) selectPbi(null)
})
}
const activePbi = activeDragId ? pbiMap[activeDragId] : null
return (
<div className="flex flex-col h-full">
<PanelNavBar
title="Product Backlog"
actions={
<>
{filterPriority !== 'all' && (
<button
onClick={() => setFilterPriority('all')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
aria-label="Wis prioriteitsfilter"
>
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
{PRIORITY_LABELS[filterPriority]}
</Badge>
<span>×</span>
</button>
)}
{filterStatus !== 'all' && (
<button
onClick={() => setFilterStatus('all')}
className="flex items-center gap-1 text-xs text-primary hover:underline"
aria-label="Wis statusfilter"
>
<Badge className={cn('text-xs', PBI_STATUS_COLORS[filterStatus])}>
{PBI_STATUS_LABELS[filterStatus]}
</Badge>
<span>×</span>
</button>
)}
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm" className="h-7 text-xs">
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
</Button>
}
/>
<PopoverContent align="end" className="w-72 space-y-4">
<FilterPills
label="Sorteren op"
options={SORT_OPTIONS}
value={sortMode}
onChange={setSortMode}
/>
<FilterPills
label="Prioriteit"
options={PRIORITY_OPTIONS}
value={filterPriority}
onChange={setFilterPriority}
/>
<FilterPills
label="Status"
options={STATUS_OPTIONS}
value={filterStatus}
onChange={setFilterStatus}
/>
<div className="flex justify-end pt-1 border-t border-border">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={activeFilterCount === 0}
onClick={() => {
setFilterPriority('all')
setFilterStatus('all')
setSortMode('priority')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
<DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
</Button>
</DemoTooltip>
</>
}
/>
<div className="flex-1 overflow-y-auto">
{pbis.length === 0 ? (
<div className="p-8 text-center text-muted-foreground text-sm space-y-3">
<p>Nog geen PBI&apos;s aangemaakt.</p>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
Maak je eerste PBI aan
</Button>
</DemoTooltip>
</div>
) : (
<DndContext
id="pbi-list"
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filtered.map(p => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="p-3 flex flex-col gap-2">
{filtered.map(pbi => (
<SortablePbiRow
key={pbi.id}
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
onSelect={() => selectPbi(pbi.id)}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activePbi && (
<BacklogCard
title={activePbi.title}
priority={activePbi.priority}
className="border-primary shadow-xl opacity-90"
/>
)}
</DragOverlay>
</DndContext>
)}
</div>
<PbiDialog
state={dialogState}
onClose={() => setDialogState(null)}
/>
</div>
)
}