Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
60f0069e00 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>
2026-04-29 17:28:40 +02:00
e632e043cf 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>
2026-04-29 17:21:39 +02:00
cde40d28c3 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>
2026-04-29 17:19:15 +02:00
72d72fd648 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>
2026-04-29 17:16:44 +02:00
2381832c67 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>
2026-04-29 17:13:36 +02:00
4cb36f7274 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>
2026-04-29 17:11:48 +02:00
a10ccc936e 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>
2026-04-29 17:10:56 +02:00
878fa161ef 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>
2026-04-29 17:09:48 +02:00
445e1522c8 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>
2026-04-29 17:08:34 +02:00
b049822f8f 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>
2026-04-29 17:07:35 +02:00
16 changed files with 767 additions and 48 deletions

View file

@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
update: vi.fn(),
},
pbi: {
findMany: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn().mockResolvedValue([]),
},
}))
import { prisma } from '@/lib/prisma'
import { completeSprintAction } from '@/actions/sprints'
const mockPrisma = prisma as unknown as {
sprint: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
pbi: {
findMany: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
}
const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' }
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT)
mockPrisma.$transaction.mockResolvedValue([])
})
describe('completeSprintAction — PBI auto-DONE cascade', () => {
it('marks PBI DONE when all its stories are decided DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'IN_SPRINT' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(1)
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
})
it('does not mark PBI DONE when a story is decided OPEN', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'IN_SPRINT' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'OPEN',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('does not mark PBI DONE when it has stories outside this sprint that are not DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'OPEN' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('marks PBI DONE when its in-sprint stories are DONE and out-of-sprint stories are already DONE', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{
id: 'pbi-1',
stories: [
{ id: 'story-a', status: 'IN_SPRINT' },
{ id: 'story-b', status: 'DONE' },
],
},
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
})
it('skips PBIs that are already DONE (promote-only)', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
// pbi.findMany filters via { status: { not: 'DONE' } } in the action,
// so an already-DONE PBI just doesn't appear in candidatePbis.
mockPrisma.pbi.findMany.mockResolvedValue([])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
expect(mockPrisma.pbi.findMany).toHaveBeenCalledWith({
where: { id: { in: ['pbi-1'] }, status: { not: 'DONE' } },
select: { id: true, stories: { select: { id: true, status: true } } },
})
})
it('cascades across multiple PBIs in one sprint close', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
{ id: 'story-b', pbi_id: 'pbi-2' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{ id: 'pbi-1', stories: [{ id: 'story-a', status: 'IN_SPRINT' }] },
{ id: 'pbi-2', stories: [{ id: 'story-b', status: 'IN_SPRINT' }] },
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
'story-b': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).toHaveBeenCalledTimes(2)
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-1' },
data: { status: 'DONE' },
})
expect(mockPrisma.pbi.update).toHaveBeenCalledWith({
where: { id: 'pbi-2' },
data: { status: 'DONE' },
})
})
it('does not include 0-story PBIs in cascade', async () => {
mockPrisma.story.findMany.mockResolvedValue([
{ id: 'story-a', pbi_id: 'pbi-1' },
])
mockPrisma.pbi.findMany.mockResolvedValue([
{ id: 'pbi-1', stories: [] },
])
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ success: true })
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
it('blocks sprint close for demo users', async () => {
const { getIronSession } = await import('iron-session')
;(getIronSession as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
userId: 'user-demo',
isDemo: true,
})
const result = await completeSprintAction('sprint-1', {
'story-a': 'DONE',
})
expect(result).toEqual({ error: 'Niet beschikbaar in demo-modus' })
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest'
import {
taskStatusToApi,
taskStatusFromApi,
storyStatusToApi,
storyStatusFromApi,
pbiStatusToApi,
pbiStatusFromApi,
TASK_STATUS_API_VALUES,
STORY_STATUS_API_VALUES,
PBI_STATUS_API_VALUES,
} from '@/lib/task-status'
describe('task-status mappers', () => {
describe('taskStatus', () => {
it('round-trips every API value', () => {
for (const api of TASK_STATUS_API_VALUES) {
const db = taskStatusFromApi(api)
expect(db).not.toBeNull()
expect(taskStatusToApi(db!)).toBe(api)
}
})
it('returns null for invalid input', () => {
expect(taskStatusFromApi('NOT_A_STATUS')).toBeNull()
})
it('is case-insensitive on the API side', () => {
expect(taskStatusFromApi('IN_PROGRESS')).toBe('IN_PROGRESS')
expect(taskStatusFromApi('In_Progress')).toBe('IN_PROGRESS')
})
})
describe('storyStatus', () => {
it('round-trips every API value', () => {
for (const api of STORY_STATUS_API_VALUES) {
const db = storyStatusFromApi(api)
expect(db).not.toBeNull()
expect(storyStatusToApi(db!)).toBe(api)
}
})
it('returns null for invalid input', () => {
expect(storyStatusFromApi('archived')).toBeNull()
})
})
describe('pbiStatus', () => {
it('round-trips every API value', () => {
for (const api of PBI_STATUS_API_VALUES) {
const db = pbiStatusFromApi(api)
expect(db).not.toBeNull()
expect(pbiStatusToApi(db!)).toBe(api)
}
})
it('maps DB to lowercase API', () => {
expect(pbiStatusToApi('READY')).toBe('ready')
expect(pbiStatusToApi('BLOCKED')).toBe('blocked')
expect(pbiStatusToApi('DONE')).toBe('done')
})
it('maps API to UPPER_SNAKE DB', () => {
expect(pbiStatusFromApi('ready')).toBe('READY')
expect(pbiStatusFromApi('blocked')).toBe('BLOCKED')
expect(pbiStatusFromApi('done')).toBe('DONE')
})
it('is case-insensitive on the API side', () => {
expect(pbiStatusFromApi('READY')).toBe('READY')
expect(pbiStatusFromApi('Blocked')).toBe('BLOCKED')
})
it('returns null for invalid input', () => {
expect(pbiStatusFromApi('archived')).toBeNull()
expect(pbiStatusFromApi('')).toBeNull()
expect(pbiStatusFromApi('todo')).toBeNull()
})
it('exposes exactly three API values', () => {
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done'])
})
})
})

View file

@ -9,6 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct } from '@/lib/product-access'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server'
import { pbiStatusFromApi } from '@/lib/task-status'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -16,12 +17,15 @@ async function getSession() {
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const statusField = z.enum(['ready', 'blocked', 'done']).optional()
const createPbiSchema = z.object({
productId: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
status: statusField,
})
const updatePbiSchema = z.object({
@ -30,6 +34,7 @@ const updatePbiSchema = z.object({
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
status: statusField,
})
export async function createPbiAction(_prevState: unknown, formData: FormData) {
@ -43,6 +48,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
title: formData.get('title'),
description: formData.get('description') || undefined,
priority: formData.get('priority'),
status: (formData.get('status') as string) || undefined,
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
@ -64,6 +70,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
})
const sort_order = (last?.sort_order ?? 0) + 1.0
const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined
const insert = (code: string | null) =>
prisma.pbi.create({
data: {
@ -73,6 +81,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
description: parsed.data.description ?? null,
priority: parsed.data.priority,
sort_order,
...(status ? { status } : {}),
},
})
@ -103,6 +112,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
title: formData.get('title'),
description: formData.get('description') || undefined,
priority: formData.get('priority'),
status: (formData.get('status') as string) || undefined,
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
@ -125,6 +135,8 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } }
}
const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined
await prisma.pbi.update({
where: { id: parsed.data.id },
data: {
@ -132,6 +144,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
title: parsed.data.title,
description: parsed.data.description ?? null,
priority: parsed.data.priority,
...(status ? { status } : {}),
},
})

View file

@ -171,10 +171,28 @@ export async function completeSprintAction(
const stories = await prisma.story.findMany({
where: { id: { in: storyIds }, sprint_id: sprintId, product_id: sprint.product_id },
select: { id: true },
select: { id: true, pbi_id: true },
})
if (stories.length !== storyIds.length) return { error: 'Ongeldige Sprint-afronding' }
const affectedPbiIds = [...new Set(stories.map((s) => s.pbi_id))]
const candidatePbis = await prisma.pbi.findMany({
where: { id: { in: affectedPbiIds }, status: { not: 'DONE' } },
select: { id: true, stories: { select: { id: true, status: true } } },
})
const decisionByStoryId = new Map(entries)
const pbiIdsToMarkDone = candidatePbis
.filter(
(pbi) =>
pbi.stories.length > 0 &&
pbi.stories.every((s) => {
const next = decisionByStoryId.get(s.id) ?? s.status
return next === 'DONE'
})
)
.map((p) => p.id)
await prisma.$transaction([
...entries.map(([storyId, status]) =>
prisma.story.update({
@ -185,6 +203,9 @@ export async function completeSprintAction(
},
})
),
...pbiIdsToMarkDone.map((id) =>
prisma.pbi.update({ where: { id }, data: { status: 'DONE' } })
),
prisma.sprint.update({
where: { id: sprintId },
data: { status: 'COMPLETED', completed_at: new Date() },

View file

@ -2,6 +2,7 @@ import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { pbiStatusToApi } from '@/lib/task-status'
import { SplitPane } from '@/components/split-pane/split-pane'
import { PbiList } from '@/components/backlog/pbi-list'
import { StoryPanel } from '@/components/backlog/story-panel'
@ -94,7 +95,7 @@ export default async function ProductBacklogPage({ params }: Props) {
left={
<PbiList
productId={id}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at }))}
pbis={pbis.map((p: (typeof pbis)[number]) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) }))}
isDemo={isDemo}
/>
}

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 }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Before After
Before After

View file

@ -0,0 +1,79 @@
# Plan — ST-1109 · PBI krijgt een status (Ready / Blocked / Done)
> Spiegel van het goedgekeurde plan dat tijdens de sessie is opgesteld in
> `~/.claude/plans/welke-rioriteiten-heeft-een-mighty-shell.md`. Vastgelegd
> in deze repo per project-conventie (zie `MEMORY.md → feedback_plan_location`).
## Context
PBI's hadden alleen `priority` en `sort_order`; Story en Task hebben wél een status. Dit maakte het onmogelijk om in de Product Backlog te zien welke PBI's klaar staan, geblokkeerd zijn of al afgerond. Een derde filter naast prioriteit + sortering zou de bestaande UI te druk maken.
**Sprint-goal:** "Beter overzicht van openstaande PBI's"
**Story:** als teamlid wil filteren op status van een PBI
## Doelen
1. Nieuwe enum `PbiStatus { READY BLOCKED DONE }`, default `READY`
2. Status zichtbaar als badge in de Product Backlog cards
3. Status manueel te zetten via PBI-dialog (alle drie de waarden)
4. Auto-cascade: bij sprint-close → als alle stories van een PBI `DONE` zijn, PBI naar `DONE` (alleen-promote)
5. Filter-UI consolideren in één shadcn `Popover` (priority + status + sort)
## Stappen (één commit per laag)
| ST-code | Laag | Bestand(en) |
|---|---|---|
| ST-1109.2 | DB | `prisma/schema.prisma` + migration |
| ST-1109.3 | API mappers | `lib/task-status.ts` |
| ST-1109.4 | Server actions | `actions/pbis.ts` |
| ST-1109.5 | Sprint-close cascade | `actions/sprints.ts` |
| ST-1109.6 | UI primitive | `components/ui/popover.tsx` (NIEUW) |
| ST-1109.7 | Dialog | `components/backlog/pbi-dialog.tsx` + `components/shared/pbi-status-select.tsx` |
| ST-1109.8 | Backlog UI | `components/backlog/pbi-list.tsx` + `app/(app)/products/[id]/page.tsx` |
| ST-1109.9 | Tests | `__tests__/lib/task-status.test.ts` + `__tests__/actions/sprints-cascade.test.ts` |
| ST-1109.10 | Docs | architecture, styling, plan-mirror |
Detail-implementatieplannen staan op de individuele MCP-tasks (`mcp__scrum4me__get_claude_context`).
## Cascade-regel (definitief)
```ts
// In completeSprintAction's prisma.$transaction([...])
const candidatePbis = await prisma.pbi.findMany({
where: { id: { in: affectedPbiIds }, status: { not: 'DONE' } },
select: { id: true, stories: { select: { id: true, status: true } } },
})
const decisionByStoryId = new Map(entries) // entries = Object.entries(decisions)
const pbiIdsToMarkDone = candidatePbis
.filter(pbi =>
pbi.stories.length > 0 &&
pbi.stories.every(s => (decisionByStoryId.get(s.id) ?? s.status) === 'DONE')
)
.map(p => p.id)
```
**Regels:**
- Promote-only: een PBI op DONE wordt nooit automatisch teruggezet
- 0-story PBI's blijven READY (ondanks dat `[].every(...) === true`)
- Stories buiten de Sprint worden meegerekend op hun huidige DB-status — een open PBL-story blokkeert de cascade
## Branch + PR
- Branch: `feat/M12-pbi-status`
- Push + PR pas **na handmatige test door gebruiker** (per Branch & PR Strategy)
- Verwachte commits: 9× `feat/test/docs(ST-1109.x)`
## Opvolgactie buiten deze repo
[`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp): de `create_pbi` tool kan straks optioneel `status` accepteren. Submodule (`vendor/scrum4me`) moet gesynced worden na merge zodat de drift-bewaking maandag groen blijft.
## Verificatie (end-to-end)
1. `npm run lint && npm test && npm run build` — alles groen
2. `npm run dev` (port 3000)
3. Maak nieuwe PBI → status default "Klaar voor sprint" (READY)
4. Edit PBI → wijzig status naar "Geblokkeerd" → save → herlaad → badge toont oranje
5. Sprint met PBI + 2 stories → close met beide DONE → PBI auto-DONE
6. Idem maar 1 story OPEN → PBI blijft READY
7. Filter-popover: open → status=Geblokkeerd → alleen blocked PBIs zichtbaar; (n)-badge klopt; "Wis filters" reset
8. Demo-user: status-velden zijn read-only / save geblokkeerd

View file

@ -123,14 +123,18 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger
|---|---|---|---|
| id | String (cuid) | PK | |
| product_id | String | FK → products (cascade delete) | |
| code | String | nullable, max 30 | Auto-gegenereerd of handmatig |
| title | String | not null, max 200 | |
| description | String | nullable, max 2000 | |
| priority | Int | 14, not null | 1 = Kritiek, 4 = Laag |
| sort_order | Float | not null | Float voor volgorde tussen items zonder renummering |
| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) |
| created_at | DateTime | default now() | |
| updated_at | DateTime | auto-update | |
**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm
**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog
**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY.
---
@ -285,6 +289,12 @@ enum StoryStatus {
DONE
}
enum PbiStatus {
READY
BLOCKED
DONE
}
enum TaskStatus {
TO_DO
IN_PROGRESS
@ -368,18 +378,22 @@ model Product {
}
model Pbi {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
code String? @db.VarChar(30)
title String
description String?
priority Int
sort_order Float
created_at DateTime @default(now())
updated_at DateTime @updatedAt
status PbiStatus @default(READY)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
@@unique([product_id, code])
@@index([product_id, priority, sort_order])
@@index([product_id, status])
}
model Story {

View file

@ -280,6 +280,12 @@ import { Badge } from '@/components/ui/badge'
<Badge variant="secondary">3 taken</Badge>
```
**PBI-status (READY / BLOCKED / DONE):** hergebruikt bestaande tokens —
`status-todo` voor READY, `status-blocked` voor BLOCKED, `status-done` voor
DONE. Centraal gedefinieerd in `components/shared/pbi-status-select.tsx`
(`PBI_STATUS_LABELS`, `PBI_STATUS_COLORS`); importeer die in plaats van
kleuren ad-hoc te kopiëren.
### Dialog (bevestigingsdialogen)
```tsx

View file

@ -1,7 +1,7 @@
// Bidirectionele case-mappers voor de REST API-boundary.
// DB houdt UPPER_SNAKE; API exposeert lowercase.
import type { TaskStatus, StoryStatus } from '@prisma/client'
import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client'
const TASK_DB_TO_API = {
TO_DO: 'todo',
@ -29,8 +29,21 @@ const STORY_API_TO_DB: Record<string, StoryStatus> = {
done: 'DONE',
}
const PBI_DB_TO_API = {
READY: 'ready',
BLOCKED: 'blocked',
DONE: 'done',
} as const satisfies Record<PbiStatus, string>
const PBI_API_TO_DB: Record<string, PbiStatus> = {
ready: 'READY',
blocked: 'BLOCKED',
done: 'DONE',
}
export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus]
export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus]
export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus]
export function taskStatusToApi(s: TaskStatus): TaskStatusApi {
return TASK_DB_TO_API[s]
@ -48,5 +61,14 @@ export function storyStatusFromApi(s: string): StoryStatus | null {
return STORY_API_TO_DB[s.toLowerCase()] ?? null
}
export function pbiStatusToApi(s: PbiStatus): PbiStatusApi {
return PBI_DB_TO_API[s]
}
export function pbiStatusFromApi(s: string): PbiStatus | null {
return PBI_API_TO_DB[s.toLowerCase()] ?? null
}
export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API)
export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API)
export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API)

View file

@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "PbiStatus" AS ENUM ('READY', 'BLOCKED', 'DONE');
-- AlterTable
ALTER TABLE "pbis" ADD COLUMN "status" "PbiStatus" NOT NULL DEFAULT 'READY';
-- CreateIndex
CREATE INDEX "pbis_product_id_status_idx" ON "pbis"("product_id", "status");

View file

@ -23,6 +23,12 @@ enum StoryStatus {
DONE
}
enum PbiStatus {
READY
BLOCKED
DONE
}
enum TaskStatus {
TO_DO
IN_PROGRESS
@ -131,12 +137,14 @@ model Pbi {
description String?
priority Int
sort_order Float
created_at DateTime @default(now())
updated_at DateTime @updatedAt
status PbiStatus @default(READY)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
stories Story[]
@@unique([product_id, code])
@@index([product_id, priority, sort_order])
@@index([product_id, status])
@@map("pbis")
}