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:
parent
50faebb82c
commit
8a9fb9d32b
16 changed files with 767 additions and 48 deletions
238
__tests__/actions/sprints-cascade.test.ts
Normal file
238
__tests__/actions/sprints-cascade.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
85
__tests__/lib/task-status.test.ts
Normal file
85
__tests__/lib/task-status.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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 } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,10 +144,16 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<label htmlFor="pbi-description" className="text-sm font-medium">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{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}
|
||||
onValueChange={(v) => setSortMode(v as 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')
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
Wis filters
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!isDemo && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
|
|
|||
41
components/shared/pbi-status-select.tsx
Normal file
41
components/shared/pbi-status-select.tsx
Normal 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
52
components/ui/popover.tsx
Normal 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 |
79
docs/plans/ST-1109-pbi-status.md
Normal file
79
docs/plans/ST-1109-pbi-status.md
Normal 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
|
||||
|
|
@ -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 | 1–4, 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
|
||||
|
|
@ -371,15 +381,19 @@ model Pbi {
|
|||
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
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue