* 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>
455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
'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'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>
|
||
)
|
||
}
|