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>
This commit is contained in:
Janpeter Visser 2026-04-29 17:52:34 +02:00 committed by GitHub
parent 50faebb82c
commit 8a9fb9d32b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 767 additions and 48 deletions

View file

@ -16,7 +16,9 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { PrioritySelect } from '@/components/shared/priority-select'
import { PbiStatusSelect } from '@/components/shared/pbi-status-select'
import { createPbiAction, updatePbiAction } from '@/actions/pbis'
import type { PbiStatusApi } from '@/lib/task-status'
export interface PbiDialogPbi {
id: string
@ -24,6 +26,7 @@ export interface PbiDialogPbi {
priority: number
description?: string | null
code?: string | null
status?: PbiStatusApi
}
type CreateState = { mode: 'create'; productId: string; defaultPriority?: number }
@ -51,11 +54,16 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
const initialPriority = isEdit ? pbi!.priority : (state?.defaultPriority ?? 2)
const [priority, setPriority] = useState<number>(initialPriority)
// Sync priority when dialog opens for a different PBI or switches create/edit mode
const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready'
const [status, setStatus] = useState<PbiStatusApi>(initialStatus)
// Sync priority + status when dialog opens for a different PBI or switches create/edit mode
useEffect(() => {
if (state) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
}
}, [state, isEdit])
@ -105,6 +113,7 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
{isEdit && <input type="hidden" name="id" value={pbi!.id} />}
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
<input type="hidden" name="priority" value={priority} />
<input type="hidden" name="status" value={status} />
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="grid gap-1.5">
@ -135,9 +144,15 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
</div>
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<PrioritySelect value={priority} onChange={setPriority} />
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<PrioritySelect value={priority} onChange={setPriority} />
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Status</label>
<PbiStatusSelect value={status} onChange={setStatus} />
</div>
</div>
<div className="grid gap-1.5">

View file

@ -23,7 +23,7 @@ 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 { 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'
@ -33,6 +33,8 @@ 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',
@ -44,6 +46,62 @@ const PRIORITY_LABELS: Record<number, string> = {
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
@ -51,6 +109,7 @@ interface Pbi {
priority: number
description?: string | null
created_at: Date
status: PbiStatusApi
}
interface PbiListProps {
@ -100,6 +159,11 @@ function SortablePbiRow({
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
@ -126,7 +190,18 @@ function SortablePbiRow({
export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
const { selectedPbiId, selectPbi } = useSelectionStore()
const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore()
const [filterPriority, setFilterPriority] = useState<number | null>(null)
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'
@ -136,6 +211,8 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
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(',')
@ -154,7 +231,16 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
.filter(Boolean)
.map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority }))
const base = filterPriority ? orderedPbis.filter(p => p.priority === filterPriority) : orderedPbis
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') {
@ -228,10 +314,11 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
title="Product Backlog"
actions={
<>
{filterPriority !== null && (
{filterPriority !== 'all' && (
<button
onClick={() => setFilterPriority(null)}
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]}
@ -239,34 +326,63 @@ export function PbiList({ productId, pbis, isDemo }: PbiListProps) {
<span>×</span>
</button>
)}
<Select
value={sortMode}
onValueChange={(v) => setSortMode(v as SortMode)}
>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="priority">Prioriteit</SelectItem>
<SelectItem value="code">Code</SelectItem>
<SelectItem value="date">Datum</SelectItem>
</SelectContent>
</Select>
<Select
value={filterPriority?.toString() ?? 'all'}
onValueChange={(v) => setFilterPriority(!v || v === 'all' ? null : parseInt(v))}
>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue placeholder="Filter" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle</SelectItem>
<SelectItem value="1">Kritiek</SelectItem>
<SelectItem value="2">Hoog</SelectItem>
<SelectItem value="3">Gemiddeld</SelectItem>
<SelectItem value="4">Laag</SelectItem>
</SelectContent>
</Select>
{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"

View file

@ -0,0 +1,41 @@
'use client'
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import type { PbiStatusApi } from '@/lib/task-status'
export const PBI_STATUS_LABELS: Record<PbiStatusApi, string> = {
ready: 'Klaar voor sprint',
blocked: 'Geblokkeerd',
done: 'Afgerond',
}
export const PBI_STATUS_COLORS: Record<PbiStatusApi, string> = {
ready: 'bg-status-todo/15 text-status-todo border-status-todo/30',
blocked: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30',
done: 'bg-status-done/15 text-status-done border-status-done/30',
}
interface PbiStatusSelectProps {
value: PbiStatusApi
onChange: (value: PbiStatusApi) => void
className?: string
}
export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) {
return (
<Select
value={value}
onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }}
>
<SelectTrigger className={cn('w-full', className)}>
{PBI_STATUS_LABELS[value] ?? value}
</SelectTrigger>
<SelectContent>
<SelectItem value="ready">Klaar voor sprint</SelectItem>
<SelectItem value="blocked">Geblokkeerd</SelectItem>
<SelectItem value="done">Afgerond</SelectItem>
</SelectContent>
</Select>
)
}

52
components/ui/popover.tsx Normal file
View file

@ -0,0 +1,52 @@
"use client"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
side = "bottom",
sideOffset = 8,
align = "center",
alignOffset = 0,
children,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 w-72 origin-(--transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
>
{children}
</PopoverPrimitive.Popup>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
export { Popover, PopoverTrigger, PopoverContent }