Compare commits
10 commits
main
...
feat/M12-p
| Author | SHA1 | Date | |
|---|---|---|---|
| 60f0069e00 | |||
| e632e043cf | |||
| cde40d28c3 | |||
| 72d72fd648 | |||
| 2381832c67 | |||
| 4cb36f7274 | |||
| a10ccc936e | |||
| 878fa161ef | |||
| 445e1522c8 | |||
| b049822f8f |
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,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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue