Scrum4Me/components/backlog/pbi-list.tsx
Janpeter Visser 8a9fb9d32b
M12 / ST-1109: PBI krijgt een status (Ready / Blocked / Done) (#16)
* feat(ST-1109.2): add PbiStatus enum and status field to Pbi model

- New PbiStatus enum (READY/BLOCKED/DONE) for PBI lifecycle tracking
- Pbi.status PbiStatus @default(READY)
- Index on (product_id, status) for filter queries
- Migration: 20260429150643_add_pbi_status
- ERD regenerated via prisma generate

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.3): add PBI status API mappers

- pbiStatusToApi / pbiStatusFromApi following same pattern as task/story
- PbiStatusApi type derived from PBI_DB_TO_API
- PBI_STATUS_API_VALUES export for downstream Zod schemas
- Lowercase API surface (ready/blocked/done), DB stays UPPER_SNAKE

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.4): support status in PBI create/update actions

- Optional status field in Zod schemas (lowercase API: ready/blocked/done)
- pbiStatusFromApi() maps to DB enum before persistence
- Status omitted on create => Prisma @default(READY) takes effect
- Update preserves existing status when not provided
- Demo-check unchanged

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.5): auto-mark PBI as DONE when all its stories are DONE on sprint close

Extends completeSprintAction's $transaction with PBI status cascade:
- Pre-transaction: identify PBIs touched by this close (via stories.pbi_id),
  fetch each with all its stories
- Skip PBIs already DONE; skip PBIs with 0 stories
- Mark PBI DONE only when every story (post-decision) is DONE — stories
  outside the sprint are evaluated against their current DB status
- Promote-only: never demotes a PBI that becomes "incomplete" again

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.6): add Popover primitive (base-ui wrapper)

- Mirrors the Tooltip pattern: render-prop composition, data-slot attrs
- Exports Popover (Root), PopoverTrigger, PopoverContent (Portal+Positioner+Popup)
- MD3 popover/popover-foreground tokens, animated open/close states
- Will be used to consolidate the backlog filter UI in ST-1109.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.7): add status select to PBI dialog

- New components/shared/pbi-status-select.tsx mirrors PrioritySelect:
  PBI_STATUS_LABELS (NL), PBI_STATUS_COLORS, PbiStatusSelect component
- Reuses existing --status-todo/blocked/done MD3 tokens
- PbiDialog: status state with sync-on-open; default 'ready' for create,
  pbi.status for edit; hidden input submits lowercase API value
- Priority + Status sit side-by-side in 2-col grid
- PbiDialogPbi.status is optional; pbi-list.tsx will populate in ST-1109.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.8): show PBI status badge and consolidate filters into popover

- Pbi.status (lowercase API) flows from page.tsx via pbiStatusToApi
- Status badge rendered in BacklogCard's badge slot using PBI_STATUS_COLORS
- Two old Select dropdowns replaced by single Popover with three pill-button
  sections (Sorteren, Prioriteit, Status) and a "Wis filters" footer
- Filter trigger shows active count "(n)" badge in label
- Active priority/status filters still surface as dismissable chips next to
  the trigger for at-a-glance feedback
- onEdit passes the full Pbi (incl. status) so the dialog opens with the
  correct current status — closes the data flow loop opened in ST-1109.7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(ST-1109.9): cover PBI status mappers and sprint-close cascade

- __tests__/lib/task-status.test.ts: 11 cases incl. round-trip + invalid
  input for task/story/pbi mappers; verifies PBI_STATUS_API_VALUES shape
- __tests__/actions/sprints-cascade.test.ts: 8 cases for completeSprintAction:
  promote on all-DONE, no promote on partial OPEN, respect out-of-sprint
  story status, skip already-DONE PBIs, multi-PBI cascade, 0-story guard,
  demo-user block
- Full vitest run: 170/170 green across 21 files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(ST-1109.10): document PbiStatus enum, sprint-close cascade, and filter UI

- docs/scrum4me-architecture.md: pbis-table updated with status column +
  index; PbiStatus enum + Pbi model in the Prisma schema sample;
  cascade-on-sprint-close rule documented inline
- docs/scrum4me-styling.md: short note pointing to PBI_STATUS_LABELS /
  PBI_STATUS_COLORS in components/shared/pbi-status-select.tsx so future
  components don't ad-hoc-copy the color map
- docs/plans/ST-1109-pbi-status.md: in-repo mirror of the approved plan
  (per feedback_plan_location memory) with cascade pseudo-code and
  end-to-end verification checklist

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ST-1109.11): persist backlog filters in localStorage

Filters reset op reload was verwarrend. Nu net als sortMode:
- scrum4me:pbi_filter_priority — 'all' | '1' | '2' | '3' | '4'
- scrum4me:pbi_filter_status — 'all' | 'ready' | 'blocked' | 'done'

useState-init met SSR-guard; ongeldige waarden vallen terug op 'all'.
Wis filters reset alle drie de keys correct (sortMode -> 'priority',
beide filters -> 'all'), waardoor de localStorage-staat consistent wordt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:52:34 +02:00

455 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 { 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={!isDemo ? (
<div className="flex items-center gap-1">
<button
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"
aria-label="Bewerk PBI"
>
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete() }}
className="text-muted-foreground hover:text-error text-xs"
aria-label="Verwijder PBI"
>
×
</button>
</div>
) : undefined}
/>
)
}
// --- 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>
{!isDemo && (
<Button
size="sm"
className="h-7 text-xs"
onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
</Button>
)}
</>
}
/>
<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>
{!isDemo && (
<Button size="sm" variant="outline" onClick={() => setDialogState({ mode: 'create', productId, defaultPriority: 2 })}>
Maak je eerste PBI aan
</Button>
)}
</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>
)
}