Merge pull request #75 from madhura68/feat/dialogs-pattern-compliance

feat(dialogs): alle dialogen conform docs/patterns/dialog.md
This commit is contained in:
Janpeter Visser 2026-05-04 07:48:42 +02:00 committed by GitHub
commit 74599669cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1669 additions and 808 deletions

View file

@ -107,7 +107,10 @@ describe('createProductAction', () => {
const result = await createProductAction(VALID_DATA) const result = await createProductAction(VALID_DATA)
expect(result).toMatchObject({ error: expect.stringContaining('gebruik') }) expect(result).toMatchObject({
code: 422,
fieldErrors: { code: expect.arrayContaining([expect.stringContaining('gebruik')]) },
})
expect(mockTransaction).not.toHaveBeenCalled() expect(mockTransaction).not.toHaveBeenCalled()
}) })

View file

@ -53,10 +53,9 @@ describe('createSprintAction — date validation', () => {
it('rejects end_date before start_date', async () => { it('rejects end_date before start_date', async () => {
const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' })
const result = await createSprintAction(undefined, fd) const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.error).toBeTruthy() expect(result.code).toBe(422)
const errors = result.error as Record<string, string[]> expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
expect(errors.end_date?.[0]).toContain('Einddatum')
}) })
it('accepts no dates (both optional)', async () => { it('accepts no dates (both optional)', async () => {
@ -81,10 +80,9 @@ describe('updateSprintDatesAction — date validation', () => {
it('rejects end_date before start_date', async () => { it('rejects end_date before start_date', async () => {
const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' })
const result = await updateSprintDatesAction(undefined, fd) const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> }
expect(result.error).toBeTruthy() expect(result.code).toBe(422)
const errors = result.error as Record<string, string[]> expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum')
expect(errors.end_date?.[0]).toContain('Einddatum')
}) })
it('blocks demo users', async () => { it('blocks demo users', async () => {

View file

@ -0,0 +1,57 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
function makeEvent(opts: Partial<KeyboardEvent>) {
return {
metaKey: false,
ctrlKey: false,
key: '',
preventDefault: vi.fn(),
...opts,
} as unknown as React.KeyboardEvent
}
describe('useDialogSubmitShortcut', () => {
it('triggert submit op Cmd+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
expect(e.preventDefault).toHaveBeenCalled()
})
it('triggert submit op Ctrl+Enter', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ ctrlKey: true, key: 'Enter' })
handler(e)
expect(submit).toHaveBeenCalledTimes(1)
})
it('triggert NIET op Enter zonder modifier', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ key: 'Enter' })
handler(e)
expect(submit).not.toHaveBeenCalled()
expect(e.preventDefault).not.toHaveBeenCalled()
})
it('triggert NIET op Cmd+andere toets', () => {
const submit = vi.fn()
const handler = useDialogSubmitShortcut(submit)
const e = makeEvent({ metaKey: true, key: 'a' })
handler(e)
expect(submit).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard'
describe('useDirtyCloseGuard', () => {
it('sluit direct als form niet dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(false, onClose))
act(() => result.current.attemptClose())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('opent confirm als form dirty is', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(true)
})
it('confirmDiscard sluit confirm en roept onClose', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
expect(result.current.confirmOpen).toBe(true)
act(() => result.current.confirmDiscard())
expect(onClose).toHaveBeenCalledTimes(1)
expect(result.current.confirmOpen).toBe(false)
})
it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => {
const onClose = vi.fn()
const { result } = renderHook(() => useDirtyCloseGuard(true, onClose))
act(() => result.current.attemptClose())
act(() => result.current.setConfirmOpen(false))
expect(onClose).not.toHaveBeenCalled()
expect(result.current.confirmOpen).toBe(false)
})
})

View file

@ -3,44 +3,24 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session' import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct } from '@/lib/product-access' import { getAccessibleProduct } from '@/lib/product-access'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { isValidCode, normalizeCode } from '@/lib/code'
import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server' import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server'
import { pbiStatusFromApi } from '@/lib/task-status' import { pbiStatusFromApi } from '@/lib/task-status'
import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
} }
const codeField = z.string().max(MAX_CODE_LENGTH).optional() type PbiFieldErrors = Record<string, string[]>
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({
id: 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,
})
export async function createPbiAction(_prevState: unknown, formData: FormData) { export async function createPbiAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = createPbiSchema.safeParse({ const parsed = createPbiSchema.safeParse({
productId: formData.get('productId'), productId: formData.get('productId'),
@ -50,18 +30,34 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
priority: formData.get('priority'), priority: formData.get('priority'),
status: (formData.get('status') as string) || undefined, status: (formData.get('status') as string) || undefined,
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors,
}
}
const product = await getAccessibleProduct(parsed.data.productId, session.userId) const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden' } if (!product) return { error: 'Product niet gevonden', code: 403 }
const manualCode = normalizeCode(parsed.data.code) const manualCode = normalizeCode(parsed.data.code)
if (manualCode !== null && !isValidCode(manualCode)) { if (manualCode !== null && !isValidCode(manualCode)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors,
}
} }
if (manualCode) { if (manualCode) {
const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } }) const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors,
}
}
} }
const last = await prisma.pbi.findFirst({ const last = await prisma.pbi.findFirst({
@ -94,7 +90,11 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
(code) => insert(code), (code) => insert(code),
) )
} catch { } catch {
return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as PbiFieldErrors,
}
} }
revalidatePath(`/products/${parsed.data.productId}`) revalidatePath(`/products/${parsed.data.productId}`)
@ -103,8 +103,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) {
export async function updatePbiAction(_prevState: unknown, formData: FormData) { export async function updatePbiAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = updatePbiSchema.safeParse({ const parsed = updatePbiSchema.safeParse({
id: formData.get('id'), id: formData.get('id'),
@ -114,25 +114,41 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
priority: formData.get('priority'), priority: formData.get('priority'),
status: (formData.get('status') as string) || undefined, status: (formData.get('status') as string) || undefined,
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors,
}
}
const pbi = await prisma.pbi.findFirst({ const pbi = await prisma.pbi.findFirst({
where: { id: parsed.data.id }, where: { id: parsed.data.id },
include: { product: true }, include: { product: true },
}) })
if (!pbi) return { error: 'PBI niet gevonden' } if (!pbi) return { error: 'PBI niet gevonden', code: 403 }
const accessible = await getAccessibleProduct(pbi.product_id, session.userId) const accessible = await getAccessibleProduct(pbi.product_id, session.userId)
if (!accessible) return { error: 'PBI niet gevonden' } if (!accessible) return { error: 'PBI niet gevonden', code: 403 }
const code = normalizeCode(parsed.data.code) const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors,
}
} }
if (code) { if (code) {
const dup = await prisma.pbi.findFirst({ const dup = await prisma.pbi.findFirst({
where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } }, where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } },
}) })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors,
}
}
} }
const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined
@ -154,16 +170,16 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) {
export async function deletePbiAction(id: string) { export async function deletePbiAction(id: string) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const pbi = await prisma.pbi.findFirst({ const pbi = await prisma.pbi.findFirst({
where: { id }, where: { id },
include: { product: true }, include: { product: true },
}) })
if (!pbi) return { error: 'PBI niet gevonden' } if (!pbi) return { error: 'PBI niet gevonden', code: 403 }
const accessible = await getAccessibleProduct(pbi.product_id, session.userId) const accessible = await getAccessibleProduct(pbi.product_id, session.userId)
if (!accessible) return { error: 'PBI niet gevonden' } if (!accessible) return { error: 'PBI niet gevonden', code: 403 }
await prisma.pbi.delete({ where: { id } }) await prisma.pbi.delete({ where: { id } })

View file

@ -10,8 +10,10 @@ import { SessionData, sessionOptions } from '@/lib/session'
import { Role } from '@prisma/client' import { Role } from '@prisma/client'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product'
const productSchema = z.object({ // Legacy FormData schema for ProductForm components (other constraints than dialog)
const productFormDataSchema = z.object({
name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'),
code: z code: z
.string() .string()
@ -29,25 +31,13 @@ const productSchema = z.object({
.max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'),
}) })
// Dialog-based schema (data-object API) type ProductFieldErrors = Partial<Record<keyof ProductInput, string[]>>
const productInput = z.object({ type ProductActionResult =
name: z.string().min(1).max(200), | { success: true; productId: string }
code: z.string().max(20).optional(), | { error: string; code?: number; fieldErrors?: ProductFieldErrors }
description: z.string().max(4000).optional(), type UpdateProductResult =
repo_url: z | { success: true }
.string() | { error: string; code?: number; fieldErrors?: ProductFieldErrors }
.url()
.regex(/^https:\/\/github\.com\//)
.optional()
.nullable(),
definition_of_done: z.string().max(4000).optional(),
auto_pr: z.boolean().default(false),
})
export type ProductInput = z.infer<typeof productInput>
type ProductActionResult = { success: true; productId: string } | { error: string }
type UpdateProductResult = { success: true } | { error: string }
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -56,20 +46,30 @@ async function getSession() {
// Data-object API used by ProductDialog // Data-object API used by ProductDialog
export async function createProductAction(data: ProductInput): Promise<ProductActionResult> { export async function createProductAction(data: ProductInput): Promise<ProductActionResult> {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = productInput.safeParse(data) const parsed = productInput.safeParse(data)
if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors,
}
}
const code = normalizeCode(parsed.data.code) const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (code !== null && !isValidCode(code)) {
return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] },
}
} }
if (code) { if (code) {
const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } })
if (dup) return { error: 'Code is al in gebruik' } if (dup) return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Code is al in gebruik'] } }
} }
const userId = session.userId const userId = session.userId
@ -97,21 +97,31 @@ export async function createProductAction(data: ProductInput): Promise<ProductAc
// Data-object API used by ProductDialog // Data-object API used by ProductDialog
export async function updateProductAction(id: string, data: ProductInput): Promise<UpdateProductResult> { export async function updateProductAction(id: string, data: ProductInput): Promise<UpdateProductResult> {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = productInput.safeParse(data) const parsed = productInput.safeParse(data)
if (!parsed.success) return { error: parsed.error.flatten().formErrors[0] ?? 'Ongeldige invoer' } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors,
}
}
const product = await prisma.product.findFirst({ const product = await prisma.product.findFirst({
where: { id, ...productAccessFilter(session.userId) }, where: { id, ...productAccessFilter(session.userId) },
select: { id: true }, select: { id: true },
}) })
if (!product) return { error: 'Product niet gevonden of geen toegang' } if (!product) return { error: 'Product niet gevonden of geen toegang', code: 403 }
const code = normalizeCode(parsed.data.code) const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (code !== null && !isValidCode(code)) {
return { error: 'Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten' } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] },
}
} }
const userId = session.userId const userId = session.userId
@ -149,7 +159,7 @@ export async function createProductFormAction(_prevState: unknown, formData: For
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = productSchema.safeParse({ const parsed = productFormDataSchema.safeParse({
name: formData.get('name'), name: formData.get('name'),
code: (formData.get('code') as string) || undefined, code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined, description: formData.get('description') || undefined,
@ -198,7 +208,7 @@ export async function updateProductFormAction(_prevState: unknown, formData: For
const id = formData.get('id') as string const id = formData.get('id') as string
if (!id) return { error: 'Product niet gevonden' } if (!id) return { error: 'Product niet gevonden' }
const parsed = productSchema.safeParse({ const parsed = productFormDataSchema.safeParse({
name: formData.get('name'), name: formData.get('name'),
code: (formData.get('code') as string) || undefined, code: (formData.get('code') as string) || undefined,
description: formData.get('description') || undefined, description: formData.get('description') || undefined,

View file

@ -12,15 +12,10 @@
// realtime updates voor andere clients. // realtime updates voor andere clients.
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { productAccessFilter } from '@/lib/product-access' import { productAccessFilter } from '@/lib/product-access'
import { answerQuestionSchema } from '@/lib/schemas/question-answer'
const inputSchema = z.object({
questionId: z.string().cuid(),
answer: z.string().min(1).max(4000),
})
type ActionResult = { ok: true } | { ok: false; error: string } type ActionResult = { ok: true } | { ok: false; error: string }
@ -32,7 +27,7 @@ export async function answerQuestion(
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } if (!session.userId) return { ok: false, error: 'Niet ingelogd' }
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' }
const parsed = inputSchema.safeParse({ questionId, answer }) const parsed = answerQuestionSchema.safeParse({ questionId, answer })
if (!parsed.success) { if (!parsed.success) {
const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer'
return { ok: false, error: first } return { ok: false, error: first }

View file

@ -3,10 +3,14 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session' import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import {
createSprintSchema,
updateSprintDatesSchema,
updateSprintGoalSchema,
} from '@/lib/schemas/sprint'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -16,39 +20,34 @@ function hasDuplicateIds(ids: string[]) {
return new Set(ids).size !== ids.length return new Set(ids).size !== ids.length
} }
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) type SprintFieldErrors = Record<string, string[]>
function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) {
if (data.start_date && data.end_date && data.end_date < data.start_date) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
}
}
export async function createSprintAction(_prevState: unknown, formData: FormData) { export async function createSprintAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = z.object({ const parsed = createSprintSchema.safeParse({
productId: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
start_date: dateField,
end_date: dateField,
}).superRefine(validateDateOrder).safeParse({
productId: formData.get('productId'), productId: formData.get('productId'),
sprint_goal: formData.get('sprint_goal'), sprint_goal: formData.get('sprint_goal'),
start_date: formData.get('start_date'), start_date: formData.get('start_date'),
end_date: formData.get('end_date'), end_date: formData.get('end_date'),
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const product = await getAccessibleProduct(parsed.data.productId, session.userId) const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden' } if (!product) return { error: 'Product niet gevonden', code: 403 }
const existing = await prisma.sprint.findFirst({ const existing = await prisma.sprint.findFirst({
where: { product_id: parsed.data.productId, status: 'ACTIVE' }, where: { product_id: parsed.data.productId, status: 'ACTIVE' },
}) })
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id } if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
const sprint = await prisma.sprint.create({ const sprint = await prisma.sprint.create({
data: { data: {
@ -66,24 +65,26 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = z.object({ const parsed = updateSprintDatesSchema.safeParse({
id: z.string(),
start_date: dateField,
end_date: dateField,
}).superRefine(validateDateOrder).safeParse({
id: formData.get('id'), id: formData.get('id'),
start_date: formData.get('start_date'), start_date: formData.get('start_date'),
end_date: formData.get('end_date'), end_date: formData.get('end_date'),
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
}) })
if (!sprint) return { error: 'Sprint niet gevonden' } if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
await prisma.sprint.update({ await prisma.sprint.update({
where: { id: parsed.data.id }, where: { id: parsed.data.id },
@ -95,19 +96,27 @@ export async function updateSprintDatesAction(_prevState: unknown, formData: For
export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const id = formData.get('id') as string const parsed = updateSprintGoalSchema.safeParse({
const sprint_goal = formData.get('sprint_goal') as string id: formData.get('id'),
if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' } sprint_goal: formData.get('sprint_goal'),
})
if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors,
}
}
const sprint = await prisma.sprint.findFirst({ const sprint = await prisma.sprint.findFirst({
where: { id, product: productAccessFilter(session.userId) }, where: { id: parsed.data.id, product: productAccessFilter(session.userId) },
}) })
if (!sprint) return { error: 'Sprint niet gevonden' } if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
await prisma.sprint.update({ where: { id }, data: { sprint_goal } }) await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } })
revalidatePath(`/products/${sprint.product_id}/sprint`) revalidatePath(`/products/${sprint.product_id}/sprint`)
return { success: true } return { success: true }
} }

View file

@ -3,18 +3,20 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session' import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session' import { SessionData, sessionOptions } from '@/lib/session'
import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access'
import { requireProductWriter } from '@/lib/auth' import { requireProductWriter } from '@/lib/auth'
import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { isValidCode, normalizeCode } from '@/lib/code'
import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server'
import { createStorySchema, updateStorySchema } from '@/lib/schemas/story'
async function getSession() { async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions) return getIronSession<SessionData>(await cookies(), sessionOptions)
} }
type StoryFieldErrors = Record<string, string[]>
async function verifyStoryAccess(storyId: string, userId: string) { async function verifyStoryAccess(storyId: string, userId: string) {
return prisma.story.findFirst({ return prisma.story.findFirst({
where: { id: storyId, product: productAccessFilter(userId) }, where: { id: storyId, product: productAccessFilter(userId) },
@ -26,31 +28,10 @@ function hasDuplicateIds(ids: string[]) {
return new Set(ids).size !== ids.length return new Set(ids).size !== ids.length
} }
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const createStorySchema = z.object({
pbiId: z.string(),
productId: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
})
const updateStorySchema = z.object({
id: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
})
export async function createStoryAction(_prevState: unknown, formData: FormData) { export async function createStoryAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = createStorySchema.safeParse({ const parsed = createStorySchema.safeParse({
pbiId: formData.get('pbiId'), pbiId: formData.get('pbiId'),
@ -61,20 +42,36 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
acceptance_criteria: formData.get('acceptance_criteria') || undefined, acceptance_criteria: formData.get('acceptance_criteria') || undefined,
priority: formData.get('priority') ?? 2, priority: formData.get('priority') ?? 2,
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors,
}
}
const pbi = await prisma.pbi.findFirst({ const pbi = await prisma.pbi.findFirst({
where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) },
}) })
if (!pbi) return { error: 'PBI niet gevonden' } if (!pbi) return { error: 'PBI niet gevonden', code: 403 }
const manualCode = normalizeCode(parsed.data.code) const manualCode = normalizeCode(parsed.data.code)
if (manualCode !== null && !isValidCode(manualCode)) { if (manualCode !== null && !isValidCode(manualCode)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors,
}
} }
if (manualCode) { if (manualCode) {
const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } }) const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors,
}
}
} }
const last = await prisma.story.findFirst({ const last = await prisma.story.findFirst({
@ -107,7 +104,11 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
(code) => insert(code), (code) => insert(code),
) )
} catch { } catch {
return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as StoryFieldErrors,
}
} }
revalidatePath(`/products/${pbi.product_id}`) revalidatePath(`/products/${pbi.product_id}`)
@ -116,8 +117,8 @@ export async function createStoryAction(_prevState: unknown, formData: FormData)
export async function updateStoryAction(_prevState: unknown, formData: FormData) { export async function updateStoryAction(_prevState: unknown, formData: FormData) {
const session = await getSession() const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' } if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = updateStorySchema.safeParse({ const parsed = updateStorySchema.safeParse({
id: formData.get('id'), id: formData.get('id'),
@ -127,20 +128,36 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData)
acceptance_criteria: formData.get('acceptance_criteria') || undefined, acceptance_criteria: formData.get('acceptance_criteria') || undefined,
priority: formData.get('priority'), priority: formData.get('priority'),
}) })
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } if (!parsed.success) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors,
}
}
const story = await verifyStoryAccess(parsed.data.id, session.userId) const story = await verifyStoryAccess(parsed.data.id, session.userId)
if (!story) return { error: 'Story niet gevonden' } if (!story) return { error: 'Story niet gevonden', code: 403 }
const code = normalizeCode(parsed.data.code) const code = normalizeCode(parsed.data.code)
if (code !== null && !isValidCode(code)) { if (code !== null && !isValidCode(code)) {
return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors,
}
} }
if (code) { if (code) {
const dup = await prisma.story.findFirst({ const dup = await prisma.story.findFirst({
where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } }, where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } },
}) })
if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } if (dup) {
return {
error: 'Validatie mislukt',
code: 422,
fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors,
}
}
} }
await prisma.story.update({ await prisma.story.update({

View file

@ -28,6 +28,17 @@ import {
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogBodyClasses,
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { PrioritySegmented } from './priority-segmented' import { PrioritySegmented } from './priority-segmented'
import { StatusSelect } from './status-select' import { StatusSelect } from './status-select'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -70,7 +81,6 @@ const textareaClass = cn(
export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) {
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [confirmClose, setConfirmClose] = useState(false)
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const isEdit = !!task const isEdit = !!task
@ -90,20 +100,8 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
router.push(closePath) router.push(closePath)
} }
function handleAttemptClose() { const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose)
if (form.formState.isDirty) { const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)())
setConfirmClose(true)
} else {
handleClose()
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
form.handleSubmit(onSubmit)()
}
}
function onSubmit(data: TaskInput) { function onSubmit(data: TaskInput) {
startTransition(async () => { startTransition(async () => {
@ -167,19 +165,14 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
return ( return (
<> <>
<Dialog open onOpenChange={(open) => { if (!open) handleAttemptClose() }}> <Dialog open onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={cn( className={entityDialogContentClasses}
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)}
> >
{/* Sticky header */} {/* Sticky header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0"> <div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold"> <DialogTitle className="text-xl font-semibold">
{isEdit ? 'Taak bewerken' : 'Nieuwe taak'} {isEdit ? 'Taak bewerken' : 'Nieuwe taak'}
</DialogTitle> </DialogTitle>
@ -196,7 +189,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</div> </div>
{/* Scrollable form body */} {/* Scrollable form body */}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6"> <div className={entityDialogBodyClasses}>
{/* Title */} {/* Title */}
<div> <div>
<label className="text-sm font-medium mb-2 block"> <label className="text-sm font-medium mb-2 block">
@ -323,7 +316,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</div> </div>
{/* Sticky footer */} {/* Sticky footer */}
<div className="border-t border-outline-variant px-6 py-4 shrink-0"> <div className={entityDialogFooterClasses}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{isEdit ? ( {isEdit ? (
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
@ -344,7 +337,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
onClick={handleAttemptClose} onClick={closeGuard.attemptClose}
disabled={isPending} disabled={isPending}
> >
Annuleren Annuleren
@ -374,27 +367,7 @@ export function TaskDialog({ task, storyId, productId, closePath, isDemo = false
</Dialog> </Dialog>
{/* Dirty-check confirm */} {/* Dirty-check confirm */}
<AlertDialog open={confirmClose} onOpenChange={setConfirmClose}> <DirtyCloseGuardDialog guard={closeGuard} />
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmClose(false)}>
Terug
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => { setConfirmClose(false); handleClose() }}
>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete confirm */} {/* Delete confirm */}
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}> <AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>

View file

@ -2,21 +2,29 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useActionState } from 'react' import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { PrioritySelect } from '@/components/shared/priority-select' import { PrioritySelect } from '@/components/shared/priority-select'
import { PbiStatusSelect } from '@/components/shared/pbi-status-select' import { PbiStatusSelect } from '@/components/shared/pbi-status-select'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogBodyClasses,
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { createPbiAction, updatePbiAction } from '@/actions/pbis' import { createPbiAction, updatePbiAction } from '@/actions/pbis'
import type { PbiStatusApi } from '@/lib/task-status' import type { PbiStatusApi } from '@/lib/task-status'
@ -36,18 +44,18 @@ export type PbiDialogState = CreateState | EditState
interface PbiDialogProps { interface PbiDialogProps {
state: PbiDialogState | null state: PbiDialogState | null
onClose: () => void onClose: () => void
isDemo?: boolean
} }
function SubmitButton({ label }: { label: string }) { interface ActionResult {
const { pending } = useFormStatus() success?: boolean
return ( error?: string
<Button type="submit" disabled={pending}> code?: number
{pending ? '…' : label} fieldErrors?: Record<string, string[]>
</Button> pbi?: unknown
)
} }
export function PbiDialog({ state, onClose }: PbiDialogProps) { export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) {
const isEdit = state?.mode === 'edit' const isEdit = state?.mode === 'edit'
const pbi = isEdit ? state.pbi : null const pbi = isEdit ? state.pbi : null
@ -57,43 +65,43 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready' const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready'
const [status, setStatus] = useState<PbiStatusApi>(initialStatus) const [status, setStatus] = useState<PbiStatusApi>(initialStatus)
// Sync priority + status when dialog opens for a different PBI or switches create/edit mode const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
// Sync priority + status + reset dirty when dialog opens for a different PBI or switches mode
useEffect(() => { useEffect(() => {
if (state) { if (state) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2)) setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready') setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
setDirty(false)
} }
}, [state, isEdit]) }, [state, isEdit])
const [createState, createAction] = useActionState( const [createState, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await createPbiAction(_prev, fd) const result = await createPbiAction(_prev, fd) as ActionResult
if (result?.success) { toast.success('PBI aangemaakt'); onClose() } if (result?.success) { toast.success('PBI aangemaakt'); onClose() }
else if (typeof result?.error === 'string') toast.error(result.error) else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const [updateState, updateAction] = useActionState( const [updateState, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await updatePbiAction(_prev, fd) const result = await updatePbiAction(_prev, fd) as ActionResult
if (result?.success) { toast.success('PBI opgeslagen'); onClose() } if (result?.success) { toast.success('PBI opgeslagen'); onClose() }
else if (typeof result?.error === 'string') toast.error(result.error) else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const pending = isEdit ? updatePending : createPending
const activeState = isEdit ? updateState : createState const activeState = isEdit ? updateState : createState
const error = typeof activeState?.error === 'string' ? activeState.error : null const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0]
const fieldError = (field: string) => {
const err = activeState?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
const titleRef = useRef<HTMLInputElement>(null) const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
@ -102,14 +110,31 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
} }
}, [state]) }, [state])
return ( const closeGuard = useDirtyCloseGuard(dirty, onClose)
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}> const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle>
</DialogHeader>
<form key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4"> return (
<>
<Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}
</DialogTitle>
</div>
<form
ref={formRef}
id="pbi-form"
key={isEdit ? pbi!.id : 'create'}
action={isEdit ? updateAction : createAction}
onChange={() => setDirty(true)}
className={entityDialogBodyClasses}
>
{isEdit && <input type="hidden" name="id" value={pbi!.id} />} {isEdit && <input type="hidden" name="id" value={pbi!.id} />}
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />} {!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
<input type="hidden" name="priority" value={priority} /> <input type="hidden" name="priority" value={priority} />
@ -124,12 +149,14 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
defaultValue={pbi?.code ?? ''} defaultValue={pbi?.code ?? ''}
placeholder={isEdit ? '' : 'auto'} placeholder={isEdit ? '' : 'auto'}
maxLength={30} maxLength={30}
disabled={isDemo}
aria-invalid={!!fieldError('code')}
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'} className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
/> />
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>} {fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label> <label htmlFor="pbi-title" className="text-sm font-medium">Titel <span className="text-error">*</span></label>
<Input <Input
id="pbi-title" id="pbi-title"
ref={titleRef} ref={titleRef}
@ -138,6 +165,8 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
placeholder="PBI-titel…" placeholder="PBI-titel…"
required required
maxLength={200} maxLength={200}
disabled={isDemo}
aria-invalid={!!fieldError('title')}
className={fieldError('title') ? 'border-error' : ''} className={fieldError('title') ? 'border-error' : ''}
/> />
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>} {fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
@ -147,11 +176,11 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label className="text-sm font-medium">Prioriteit</label> <label className="text-sm font-medium">Prioriteit</label>
<PrioritySelect value={priority} onChange={setPriority} /> <PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} />
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label className="text-sm font-medium">Status</label> <label className="text-sm font-medium">Status</label>
<PbiStatusSelect value={status} onChange={setStatus} /> <PbiStatusSelect value={status} onChange={(v) => { setStatus(v); setDirty(true) }} />
</div> </div>
</div> </div>
@ -166,20 +195,27 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
placeholder="Korte omschrijving van het PBI…" placeholder="Korte omschrijving van het PBI…"
rows={3} rows={3}
maxLength={2000} maxLength={2000}
disabled={isDemo}
className="resize-none" className="resize-none"
/> />
</div> </div>
{error && <p className="text-xs text-error">{error}</p>}
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Annuleren
</DialogClose>
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} />
</DialogFooter>
</form> </form>
<div className={entityDialogFooterClasses}>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button type="submit" form="pbi-form" disabled={pending || isDemo}>
{pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
) )
} }

View file

@ -460,6 +460,7 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
<PbiDialog <PbiDialog
state={dialogState} state={dialogState}
onClose={() => setDialogState(null)} onClose={() => setDialogState(null)}
isDemo={isDemo}
/> />
</div> </div>
) )

View file

@ -1,17 +1,24 @@
'use client' 'use client'
import { useEffect, useRef, useState, useTransition } from 'react' import { useEffect, useRef, useState, useTransition } from 'react'
import { Markdown } from '@/components/markdown'
import { useActionState } from 'react' import { useActionState } from 'react'
import { useFormStatus } from 'react-dom' import { Markdown } from '@/components/markdown'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@ -19,6 +26,16 @@ import { Badge } from '@/components/ui/badge'
import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select' import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select'
import { StoryLog } from '@/components/shared/story-log' import { StoryLog } from '@/components/shared/story-log'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories' import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { Story } from './story-panel' import type { Story } from './story-panel'
@ -33,6 +50,14 @@ interface StoryDialogProps {
isDemo?: boolean isDemo?: boolean
} }
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
story?: unknown
}
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30', OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
@ -44,15 +69,6 @@ const STATUS_LABELS: Record<string, string> = {
DONE: 'Klaar', DONE: 'Klaar',
} }
function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={disabled || pending}>
{pending ? '…' : label}
</Button>
)
}
export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps) { export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps) {
const isEdit = state?.mode === 'edit' const isEdit = state?.mode === 'edit'
const story = isEdit ? (state as Extract<StoryDialogState, { mode: 'edit' }>).story : null const story = isEdit ? (state as Extract<StoryDialogState, { mode: 'edit' }>).story : null
@ -62,52 +78,50 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
const [confirmDelete, setConfirmDelete] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false)
const [isDeleting, startDeleteTransition] = useTransition() const [isDeleting, startDeleteTransition] = useTransition()
const [logs, setLogs] = useState<Awaited<ReturnType<typeof getStoryLogsAction>> | null>(null) const [logs, setLogs] = useState<Awaited<ReturnType<typeof getStoryLogsAction>> | null>(null)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => { useEffect(() => {
if (!state) return if (!state) return
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setConfirmDelete(false) setConfirmDelete(false)
setDirty(false)
if (state.mode === 'edit') { if (state.mode === 'edit') {
setPriority(state.story.priority) setPriority(state.story.priority)
setLogs(null) setLogs(null)
getStoryLogsAction(state.story.id).then(setLogs) getStoryLogsAction(state.story.id).then(setLogs)
} else { } else {
setPriority(state.defaultPriority ?? 2) setPriority(state.defaultPriority ?? 2)
} }
}, [state]) }, [state])
const [createResult, createAction] = useActionState( const [createResult, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await createStoryAction(_prev, fd) const result = await createStoryAction(_prev, fd) as ActionResult
if (result?.success) { toast.success('Story aangemaakt'); onClose() } if (result?.success) { toast.success('Story aangemaakt'); onClose() }
else if (typeof result?.error === 'string') toast.error(result.error) else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const [updateResult, updateAction] = useActionState( const [updateResult, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await updateStoryAction(_prev, fd) const result = await updateStoryAction(_prev, fd) as ActionResult
if (result?.success) { toast.success('Story opgeslagen'); onClose() } if (result?.success) { toast.success('Story opgeslagen'); onClose() }
else if (typeof result?.error === 'string') toast.error(result.error) else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const fieldError = (field: string) => { const pending = isEdit ? updatePending : createPending
const result = isEdit ? updateResult : createResult const activeResult = isEdit ? updateResult : createResult
const err = result?.error const fieldError = (field: string) => activeResult?.fieldErrors?.[field]?.[0]
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
function handleDelete() { function handleDelete() {
if (!story) return if (!story) return
setConfirmDelete(false)
startDeleteTransition(async () => { startDeleteTransition(async () => {
const result = await deleteStoryAction(story.id) const result = await deleteStoryAction(story.id)
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
@ -121,14 +135,24 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
if (state) setTimeout(() => titleRef.current?.focus(), 50) if (state) setTimeout(() => titleRef.current?.focus(), 50)
}, [state]) }, [state])
const closeGuard = useDirtyCloseGuard(dirty, onClose)
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
const showForm = !isDemo || !isEdit const showForm = !isDemo || !isEdit
return ( return (
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}> <>
<DialogContent className="sm:max-w-lg flex flex-col gap-0 p-0 max-h-[90vh] overflow-hidden"> <Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 pr-14"> <DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={cn(entityDialogHeaderClasses, 'flex-col items-stretch gap-1')}>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<DialogTitle className="flex-1">{isEdit ? story!.title : 'Nieuwe story'}</DialogTitle> <DialogTitle className="flex-1 text-xl font-semibold">
{isEdit ? story!.title : 'Nieuwe story'}
</DialogTitle>
{isEdit && story!.code && ( {isEdit && story!.code && (
<span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0 mt-0.5"> <span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0 mt-0.5">
{story!.code} {story!.code}
@ -136,7 +160,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)} )}
</div> </div>
{isEdit && ( {isEdit && (
<div className="flex gap-2 mt-1"> <div className="flex gap-2">
<Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}> <Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]} {PRIORITY_LABELS[priority]}
</Badge> </Badge>
@ -145,12 +169,15 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
</Badge> </Badge>
</div> </div>
)} )}
</DialogHeader> </div>
<form <form
ref={formRef}
id="story-form"
key={isEdit ? story!.id : 'create'} key={isEdit ? story!.id : 'create'}
action={isEdit ? updateAction : createAction} action={isEdit ? updateAction : createAction}
className="flex flex-col min-h-0 flex-1" onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto"
> >
{isEdit && <input type="hidden" name="id" value={story!.id} />} {isEdit && <input type="hidden" name="id" value={story!.id} />}
{!isEdit && ( {!isEdit && (
@ -161,9 +188,8 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)} )}
<input type="hidden" name="priority" value={priority} /> <input type="hidden" name="priority" value={priority} />
<div className="flex-1 overflow-y-auto">
{showForm ? ( {showForm ? (
<div className="p-5 space-y-4"> <div className="px-6 py-6 space-y-6">
<div className="grid grid-cols-[6rem_1fr] gap-3"> <div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label>
@ -172,18 +198,24 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
defaultValue={story?.code ?? ''} defaultValue={story?.code ?? ''}
placeholder={isEdit ? '' : 'auto'} placeholder={isEdit ? '' : 'auto'}
maxLength={30} maxLength={30}
disabled={isDemo}
aria-invalid={!!fieldError('code')}
className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')} className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')}
/> />
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>} {fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Titel <span className="text-error">*</span>
</label>
<Input <Input
ref={titleRef} ref={titleRef}
name="title" name="title"
defaultValue={story?.title ?? ''} defaultValue={story?.title ?? ''}
required required
maxLength={200} maxLength={200}
disabled={isDemo}
aria-invalid={!!fieldError('title')}
className={fieldError('title') ? 'border-error' : ''} className={fieldError('title') ? 'border-error' : ''}
/> />
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>} {fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
@ -192,7 +224,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</label> <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</label>
<PrioritySelect value={priority} onChange={setPriority} /> <PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -204,6 +236,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
rows={3} rows={3}
defaultValue={story?.description ?? ''} defaultValue={story?.description ?? ''}
placeholder="Als… wil ik… zodat…" placeholder="Als… wil ik… zodat…"
disabled={isDemo}
className="resize-none" className="resize-none"
/> />
</div> </div>
@ -217,18 +250,13 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
rows={3} rows={3}
defaultValue={story?.acceptance_criteria ?? ''} defaultValue={story?.acceptance_criteria ?? ''}
placeholder="- Gegeven… Als… Dan…" placeholder="- Gegeven… Als… Dan…"
disabled={isDemo}
className="resize-none" className="resize-none"
/> />
</div> </div>
{typeof (isEdit ? updateResult?.error : createResult?.error) === 'string' && (
<p className="text-xs text-error">
{String(isEdit ? updateResult?.error : createResult?.error)}
</p>
)}
</div> </div>
) : ( ) : (
<div className="p-5 space-y-4"> <div className="px-6 py-6 space-y-6">
{story?.description && ( {story?.description && (
<div> <div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p>
@ -245,7 +273,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)} )}
{isEdit && ( {isEdit && (
<div className="px-5 py-4 border-t border-border"> <div className="px-6 py-4 border-t border-outline-variant">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Activiteitenlog</p> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Activiteitenlog</p>
{logs && 'logs' in logs && logs.logs ? ( {logs && 'logs' in logs && logs.logs ? (
<StoryLog <StoryLog
@ -262,49 +290,59 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)} )}
</div> </div>
)} )}
</div> </form>
{isEdit && ( <div className={entityDialogFooterClasses}>
<div className="px-5 py-3 border-t border-border shrink-0"> <div className="flex items-center justify-between gap-2">
{!isDemo && confirmDelete ? ( {isEdit ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground flex-1">
Weet je het zeker? Taken worden ook verwijderd.
</span>
<Button type="button" variant="destructive" size="sm" disabled={isDeleting} onClick={handleDelete}>
{isDeleting ? 'Bezig…' : 'Verwijderen'}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setConfirmDelete(false)}>
Annuleren
</Button>
</div>
) : (
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button <Button
type="button" type="button"
variant="ghost" variant="destructive"
size="sm" disabled={isDemo || isDeleting || pending}
className="text-error hover:bg-error/10" onClick={() => setConfirmDelete(true)}
disabled={isDemo}
onClick={() => !isDemo && setConfirmDelete(true)}
> >
Story verwijderen Verwijderen
</Button> </Button>
</DemoTooltip> </DemoTooltip>
) : (
<div />
)} )}
</div> <div className="flex gap-2">
)} <Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
<div className="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0 rounded-b-xl bg-muted/50">
<DialogClose render={<Button type="button" variant="outline" />}>
Annuleren Annuleren
</DialogClose> </Button>
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} disabled={isDemo} /> <Button type="submit" form="story-form" disabled={pending || isDemo}>
{pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip> </DemoTooltip>
</div> </div>
</form> </div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Story verwijderen</AlertDialogTitle>
<AlertDialogDescription>
Weet je het zeker? Bijbehorende taken worden ook verwijderd. Dit kan niet ongedaan worden.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmDelete(false)}>
Annuleren
</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={isDeleting} onClick={handleDelete}>
{isDeleting ? 'Bezig…' : 'Verwijderen'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) )
} }

View file

@ -3,35 +3,32 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogBodyClasses,
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { productSchema, type ProductInput } from '@/lib/schemas/product'
import { createProductAction, updateProductAction } from '@/actions/products' import { createProductAction, updateProductAction } from '@/actions/products'
import { useProductsStore } from '@/stores/products-store' import { useProductsStore } from '@/stores/products-store'
const formSchema = z.object({
name: z.string().min(1, 'Naam is verplicht').max(200),
code: z.string().max(20).optional(),
description: z.string().max(4000).optional(),
repo_url: z.string().max(200).optional(),
definition_of_done: z.string().max(4000).optional(),
auto_pr: z.boolean(),
})
type FormValues = z.infer<typeof formSchema>
export interface ProductDialogProduct { export interface ProductDialogProduct {
id: string id: string
name: string name: string
@ -54,8 +51,9 @@ export function ProductDialog(props: Props) {
const [isPending, setIsPending] = useState(false) const [isPending, setIsPending] = useState(false)
const form = useForm<FormValues>({ const form = useForm<ProductInput>({
resolver: zodResolver(formSchema), resolver: zodResolver(productSchema),
mode: 'onTouched',
defaultValues: { defaultValues: {
name: product?.name ?? '', name: product?.name ?? '',
code: product?.code ?? '', code: product?.code ?? '',
@ -66,7 +64,7 @@ export function ProductDialog(props: Props) {
}, },
}) })
// Reset form when dialog opens or switches product // Reset when opening or switching product
useEffect(() => { useEffect(() => {
if (open) { if (open) {
form.reset({ form.reset({
@ -81,14 +79,13 @@ export function ProductDialog(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, product?.id]) }, [open, product?.id])
async function onSubmit(values: FormValues) { const closeGuard = useDirtyCloseGuard(form.formState.isDirty, () => onOpenChange(false))
if (isDemo) { const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)())
toast.error('Niet beschikbaar in demo-modus')
return async function onSubmit(values: ProductInput) {
}
setIsPending(true) setIsPending(true)
try { try {
const payload = { const payload: ProductInput = {
name: values.name, name: values.name,
code: values.code || undefined, code: values.code || undefined,
description: values.description || undefined, description: values.description || undefined,
@ -97,14 +94,29 @@ export function ProductDialog(props: Props) {
auto_pr: values.auto_pr, auto_pr: values.auto_pr,
} }
function applyError(result: { error: string; code?: number; fieldErrors?: Partial<Record<keyof ProductInput, string[]>> }) {
if (result.code === 422 && result.fieldErrors) {
for (const [field, errors] of Object.entries(result.fieldErrors)) {
if (errors && errors.length > 0) {
form.setError(field as keyof ProductInput, { message: errors[0] })
}
}
const firstError = Object.keys(result.fieldErrors)[0] as keyof ProductInput | undefined
if (firstError) form.setFocus(firstError)
return
}
toast.error(result.error)
}
if (mode === 'create') { if (mode === 'create') {
const result = await createProductAction(payload) const result = await createProductAction(payload)
if ('error' in result) { if ('error' in result) {
toast.error(result.error) applyError(result)
return return
} }
const productId = result.productId
addProduct({ addProduct({
id: result.productId, id: productId,
name: values.name, name: values.name,
code: values.code ?? null, code: values.code ?? null,
description: values.description ?? null, description: values.description ?? null,
@ -114,11 +126,11 @@ export function ProductDialog(props: Props) {
}) })
toast.success('Product aangemaakt') toast.success('Product aangemaakt')
onOpenChange(false) onOpenChange(false)
props.onSaved?.(result.productId) props.onSaved?.(productId)
} else { } else {
const result = await updateProductAction(product!.id, payload) const result = await updateProductAction(product!.id, payload)
if ('error' in result) { if ('error' in result) {
toast.error(result.error) applyError(result)
return return
} }
updateProduct(product!.id, { updateProduct(product!.id, {
@ -141,16 +153,23 @@ export function ProductDialog(props: Props) {
const autoPr = form.watch('auto_pr') const autoPr = form.watch('auto_pr')
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-lg"> <Dialog open={open} onOpenChange={(v) => { if (!v) closeGuard.attemptClose(); else onOpenChange(v) }}>
<DialogHeader> <DialogContent
<DialogTitle>{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}</DialogTitle> showCloseButton={false}
</DialogHeader> onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}
</DialogTitle>
</div>
<form <form
id="product-form" id="product-form"
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4" className={entityDialogBodyClasses}
> >
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label htmlFor="product-name" className="text-sm font-medium"> <label htmlFor="product-name" className="text-sm font-medium">
@ -161,6 +180,7 @@ export function ProductDialog(props: Props) {
autoFocus={mode === 'create'} autoFocus={mode === 'create'}
disabled={isDemo} disabled={isDemo}
maxLength={200} maxLength={200}
aria-invalid={!!form.formState.errors.name}
{...form.register('name')} {...form.register('name')}
className={form.formState.errors.name ? 'border-error' : ''} className={form.formState.errors.name ? 'border-error' : ''}
/> />
@ -178,9 +198,13 @@ export function ProductDialog(props: Props) {
disabled={isDemo} disabled={isDemo}
maxLength={20} maxLength={20}
placeholder="korte slug, bv. SCRUM4ME" placeholder="korte slug, bv. SCRUM4ME"
className="font-mono text-sm" aria-invalid={!!form.formState.errors.code}
className={cn('font-mono text-sm', form.formState.errors.code && 'border-error')}
{...form.register('code')} {...form.register('code')}
/> />
{form.formState.errors.code && (
<p className="text-xs text-error">{form.formState.errors.code.message}</p>
)}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
@ -205,8 +229,13 @@ export function ProductDialog(props: Props) {
id="product-repo-url" id="product-repo-url"
disabled={isDemo} disabled={isDemo}
placeholder="https://github.com/owner/repo" placeholder="https://github.com/owner/repo"
aria-invalid={!!form.formState.errors.repo_url}
{...form.register('repo_url')} {...form.register('repo_url')}
className={form.formState.errors.repo_url ? 'border-error' : ''}
/> />
{form.formState.errors.repo_url && (
<p className="text-xs text-error">{form.formState.errors.repo_url.message}</p>
)}
</div> </div>
<div className="grid gap-1.5"> <div className="grid gap-1.5">
@ -229,7 +258,7 @@ export function ProductDialog(props: Props) {
role="switch" role="switch"
aria-checked={autoPr} aria-checked={autoPr}
disabled={isDemo} disabled={isDemo}
onClick={() => form.setValue('auto_pr', !autoPr)} onClick={() => form.setValue('auto_pr', !autoPr, { shouldDirty: true })}
className={cn( className={cn(
'relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent', 'relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
@ -254,17 +283,26 @@ export function ProductDialog(props: Props) {
</div> </div>
</form> </form>
<DialogFooter> <div className={entityDialogFooterClasses}>
<DialogClose render={<Button type="button" variant="outline" />}> <div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren Annuleren
</DialogClose> </Button>
<DemoTooltip show={isDemo}> <DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo}> <Button type="submit" form="product-form" disabled={isPending || isDemo}>
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'} {isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
</Button> </Button>
</DemoTooltip> </DemoTooltip>
</DialogFooter> </div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
) )
} }

View file

@ -2,11 +2,11 @@
// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11). // ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11).
// //
// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de // Free-text Textarea (max ANSWER_MAX_CHARS) of multiple-choice via knoppen
// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via // wanneer de vraag `options` heeft. Submit roept answerQuestion-Server-Action
// useTransition; bij succes wordt de vraag uit de store verwijderd // aan via useTransition; bij succes wordt de vraag uit de store verwijderd
// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit // (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit
// disabled met tooltip. // disabled met DemoTooltip.
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import Link from 'next/link' import Link from 'next/link'
@ -16,18 +16,25 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader,
DialogTitle, DialogTitle,
DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { ANSWER_MAX_CHARS } from '@/lib/schemas/question-answer'
import { answerQuestion } from '@/actions/questions' import { answerQuestion } from '@/actions/questions'
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
const MAX_ANSWER_CHARS = 4000
interface AnswerModalProps { interface AnswerModalProps {
question: NotificationQuestion | null question: NotificationQuestion | null
isDemo: boolean isDemo: boolean
@ -38,26 +45,23 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
const [answer, setAnswer] = useState('') const [answer, setAnswer] = useState('')
const [pending, startTransition] = useTransition() const [pending, startTransition] = useTransition()
if (!question) return null const closeGuard = useDirtyCloseGuard(answer.trim().length > 0, () => {
setAnswer('')
onClose()
})
const charsLeft = MAX_ANSWER_CHARS - answer.length const charsLeft = ANSWER_MAX_CHARS - answer.length
const tooLong = charsLeft < 0 const tooLong = charsLeft < 0
const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong
function submit(text: string) { function submit(text: string) {
if (!question) return if (!question) return
if (isDemo) {
toast.error('Niet beschikbaar in demo-modus')
return
}
startTransition(async () => { startTransition(async () => {
const res = await answerQuestion(question.id, text) const res = await answerQuestion(question.id, text)
if (!res.ok) { if (!res.ok) {
toast.error(res.error) toast.error(res.error)
return return
} }
// Optimistisch verwijderen — SSE-event komt anders later met dezelfde
// remove en kost een extra render
useNotificationsStore.getState().remove(question.id) useNotificationsStore.getState().remove(question.id)
toast.success('Antwoord verstuurd') toast.success('Antwoord verstuurd')
setAnswer('') setAnswer('')
@ -65,17 +69,32 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
}) })
} }
const handleKeyDown = useDialogSubmitShortcut(() => {
if (!submitDisabled) submit(answer)
})
if (!question) return null
return ( return (
<Dialog open={!!question} onOpenChange={(open) => !open && onClose()}> <>
<DialogContent className="sm:max-w-lg"> <Dialog open={!!question} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogHeader> <DialogContent
<DialogTitle>Beantwoord Claude</DialogTitle> showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle>
<DialogDescription> <DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span> <span className="font-mono">{question.story_code ?? 'story'}</span>
{' — '} {' — '}
{question.story_title} {question.story_title}
</DialogDescription> </DialogDescription>
</DialogHeader> </div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<Link <Link
href={`/products/${question.product_id}/sprint`} href={`/products/${question.product_id}/sprint`}
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline" className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
@ -93,8 +112,8 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
<p className="text-muted-foreground text-xs">Kies een van de opties:</p> <p className="text-muted-foreground text-xs">Kies een van de opties:</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{question.options.map((opt) => ( {question.options.map((opt) => (
<DemoTooltip key={opt} show={isDemo}>
<Button <Button
key={opt}
type="button" type="button"
variant="outline" variant="outline"
className="justify-start" className="justify-start"
@ -103,6 +122,7 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
> >
{opt} {opt}
</Button> </Button>
</DemoTooltip>
))} ))}
</div> </div>
</div> </div>
@ -113,8 +133,8 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
onChange={(e) => setAnswer(e.target.value)} onChange={(e) => setAnswer(e.target.value)}
placeholder="Typ je antwoord…" placeholder="Typ je antwoord…"
rows={5} rows={5}
maxLength={MAX_ANSWER_CHARS} maxLength={ANSWER_MAX_CHARS}
readOnly={isDemo} disabled={isDemo}
aria-label="Antwoord op Claude's vraag" aria-label="Antwoord op Claude's vraag"
/> />
<div <div
@ -128,30 +148,28 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
</div> </div>
</div> </div>
)} )}
</div>
<DialogFooter> <div className={entityDialogFooterClasses}>
<Button variant="ghost" onClick={onClose} disabled={pending}> <div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren Annuleren
</Button> </Button>
{(!question.options || question.options.length === 0) && ( {(!question.options || question.options.length === 0) && (
<TooltipProvider> <DemoTooltip show={isDemo}>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<Button <Button
onClick={() => submit(answer)} onClick={() => submit(answer)}
disabled={submitDisabled} disabled={submitDisabled}
> >
{pending ? 'Bezig…' : 'Verstuur'} {pending ? 'Bezig…' : 'Verstuur'}
</Button> </Button>
</TooltipTrigger> </DemoTooltip>
{isDemo && (
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
)} )}
</Tooltip> </div>
</TooltipProvider> </div>
)}
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
) )
} }

View file

@ -0,0 +1,16 @@
import { cn } from '@/lib/utils'
export const entityDialogContentClasses = cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)
export const entityDialogHeaderClasses =
'flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0'
export const entityDialogBodyClasses = 'flex-1 overflow-y-auto px-6 py-6 space-y-6'
export const entityDialogFooterClasses =
'border-t border-outline-variant px-6 py-4 shrink-0'

View file

@ -0,0 +1,10 @@
import type { KeyboardEvent } from 'react'
export function useDialogSubmitShortcut(submit: () => void) {
return (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
submit()
}
}
}

View file

@ -0,0 +1,66 @@
'use client'
import { useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
export interface DirtyCloseGuard {
confirmOpen: boolean
setConfirmOpen: (v: boolean) => void
attemptClose: () => void
confirmDiscard: () => void
}
export function useDirtyCloseGuard(
isDirty: boolean,
onClose: () => void,
): DirtyCloseGuard {
const [confirmOpen, setConfirmOpen] = useState(false)
function attemptClose() {
if (isDirty) setConfirmOpen(true)
else onClose()
}
function confirmDiscard() {
setConfirmOpen(false)
onClose()
}
return { confirmOpen, setConfirmOpen, attemptClose, confirmDiscard }
}
export function DirtyCloseGuardDialog({
guard,
}: {
guard: DirtyCloseGuard
}) {
return (
<AlertDialog open={guard.confirmOpen} onOpenChange={guard.setConfirmOpen}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle>
<AlertDialogDescription>
Wil je de wijzigingen weggooien?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => guard.setConfirmOpen(false)}>
Terug
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={guard.confirmDiscard}>
Weggooien
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,8 +1,13 @@
'use client' 'use client'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
interface BatchEnqueueBlockerDialogProps { interface BatchEnqueueBlockerDialogProps {
open: boolean open: boolean
@ -32,12 +37,12 @@ export function BatchEnqueueBlockerDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md"> <DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<DialogHeader> <div className={entityDialogHeaderClasses}>
<DialogTitle>Blokkade gedetecteerd</DialogTitle> <DialogTitle className="text-xl font-semibold">Blokkade gedetecteerd</DialogTitle>
</DialogHeader> </div>
<div className="space-y-3 py-2 text-sm text-foreground"> <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6 text-sm text-foreground">
<p> <p>
{BLOCKER_REASON_LABELS[blockerReason]}:{' '} {BLOCKER_REASON_LABELS[blockerReason]}:{' '}
<span className="font-medium">{blockerLabel}</span>. <span className="font-medium">{blockerLabel}</span>.
@ -53,7 +58,8 @@ export function BatchEnqueueBlockerDialog({
)} )}
</div> </div>
<div className="flex justify-end gap-2 pt-2 border-t border-outline-variant"> <div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}> <Button variant="ghost" onClick={onCancel}>
Annuleer Annuleer
</Button> </Button>
@ -81,6 +87,7 @@ export function BatchEnqueueBlockerDialog({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View file

@ -5,6 +5,7 @@ import Link from 'next/link'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Markdown } from '@/components/markdown' import { Markdown } from '@/components/markdown'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@ -373,7 +374,7 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe
export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) { export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) {
return ( return (
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}> <Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg"> <DialogContent showCloseButton={false} className={entityDialogContentClasses}>
{task && ( {task && (
<TaskDetailContent <TaskDetailContent
key={task.id} key={task.id}

View file

@ -1,17 +1,25 @@
'use client' 'use client'
import { useState, useTransition, useActionState } from 'react' import { useState, useTransition, useActionState, useRef } from 'react'
import { useFormStatus } from 'react-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DemoTooltip } from '@/components/shared/demo-tooltip' import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints' import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints'
import type { SprintStory } from './sprint-backlog' import type { SprintStory } from './sprint-backlog'
@ -31,9 +39,11 @@ interface SprintHeaderProps {
sprintStories: SprintStory[] sprintStories: SprintStory[]
} }
function SaveGoalButton() { interface ActionResult {
const { pending } = useFormStatus() success?: boolean
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button> error?: string
code?: number
fieldErrors?: Record<string, string[]>
} }
function toDateInputValue(d: Date | null) { function toDateInputValue(d: Date | null) {
@ -47,33 +57,39 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
const [completeOpen, setCompleteOpen] = useState(false) const [completeOpen, setCompleteOpen] = useState(false)
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({}) const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
const [isCompleting, startCompleting] = useTransition() const [isCompleting, startCompleting] = useTransition()
const [datesDirty, setDatesDirty] = useState(false)
const datesFormRef = useRef<HTMLFormElement>(null)
const [, goalFormAction] = useActionState( const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await updateSprintGoalAction(_prev, fd) const result = await updateSprintGoalAction(_prev, fd) as ActionResult
if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') } if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') else if (result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const [datesState, datesFormAction] = useActionState( const [datesState, datesFormAction, datesPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await updateSprintDatesAction(_prev, fd) const result = await updateSprintDatesAction(_prev, fd) as ActionResult
if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') } if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') else if (result?.code !== 422 && result?.error) toast.error(result.error)
return result return result
}, },
undefined undefined,
) )
const datesFieldError = (field: string) => datesState?.fieldErrors?.[field]?.[0]
const datesCloseGuard = useDirtyCloseGuard(datesDirty, () => setEditingDates(false))
const datesKeyDown = useDialogSubmitShortcut(() => datesFormRef.current?.requestSubmit())
function setDecision(storyId: string, value: 'DONE' | 'OPEN') { function setDecision(storyId: string, value: 'DONE' | 'OPEN') {
setDecisions(prev => ({ ...prev, [storyId]: value })) setDecisions(prev => ({ ...prev, [storyId]: value }))
} }
function handleComplete() { function handleComplete() {
// Default: stories without explicit decision → OPEN
const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {} const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {}
sprintStories.forEach(s => { sprintStories.forEach(s => {
finalDecisions[s.id] = decisions[s.id] ?? 'OPEN' finalDecisions[s.id] = decisions[s.id] ?? 'OPEN'
@ -101,7 +117,9 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
<input type="hidden" name="id" value={sprint.id} /> <input type="hidden" name="id" value={sprint.id} />
<Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus /> <Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<SaveGoalButton /> <Button type="submit" size="sm" disabled={goalPending}>
{goalPending ? 'Opslaan…' : 'Opslaan'}
</Button>
<Button type="button" size="sm" variant="ghost" aria-label="Annuleer bewerken" onClick={() => setEditingGoal(false)}>×</Button> <Button type="button" size="sm" variant="ghost" aria-label="Annuleer bewerken" onClick={() => setEditingGoal(false)}>×</Button>
</div> </div>
</form> </form>
@ -131,51 +149,66 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div> </div>
{/* Dates edit dialog */} {/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}> <Dialog open={editingDates} onOpenChange={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}>
<DialogContent className="sm:max-w-sm"> <DialogContent
<DialogHeader> showCloseButton={false}
<DialogTitle>Sprint datums instellen</DialogTitle> onKeyDown={datesKeyDown}
</DialogHeader> className={entityDialogContentClasses}
<form action={datesFormAction} className="space-y-4 p-1"> >
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint datums instellen</DialogTitle>
</div>
<form
ref={datesFormRef}
id="sprint-dates-form"
action={datesFormAction}
onChange={() => setDatesDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="id" value={sprint.id} /> <input type="hidden" name="id" value={sprint.id} />
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label> <label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> <input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && ( {datesFieldError('start_date') && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p> <p className="text-xs text-error">{datesFieldError('start_date')}</p>
)} )}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label> <label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> <input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && ( {datesFieldError('end_date') && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p> <p className="text-xs text-error">{datesFieldError('end_date')}</p>
)} )}
</div> </div>
</div> </div>
{typeof datesState?.error === 'string' && (
<p className="text-xs text-error">{datesState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
<Button type="submit">Opslaan</Button>
</div>
</form> </form>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={datesCloseGuard.attemptClose} disabled={datesPending}>
Annuleren
</Button>
<Button type="submit" form="sprint-dates-form" disabled={datesPending}>
{datesPending ? '…' : 'Opslaan'}
</Button>
</div>
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={datesCloseGuard} />
{/* Complete sprint dialog */} {/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}> <Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl"> <DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<DialogHeader> <div className={entityDialogHeaderClasses}>
<DialogTitle>Sprint afronden</DialogTitle> <DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle>
</DialogHeader> </div>
<div className="space-y-4 p-1"> <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Geef per story aan wat er mee moet gebeuren: Geef per story aan wat er mee moet gebeuren:
</p> </p>
<div className="space-y-2 max-h-64 overflow-y-auto"> <div className="space-y-2">
{sprintStories.map(story => ( {sprintStories.map(story => (
<div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg"> <div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg">
{story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>} {story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>}
@ -197,11 +230,17 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div> </div>
))} ))}
</div> </div>
</div>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button> <Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}>
<Button disabled={isCompleting} onClick={handleComplete}> Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button disabled={isCompleting || isDemo} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'} {isCompleting ? 'Bezig…' : 'Sprint afronden'}
</Button> </Button>
</DemoTooltip>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>

View file

@ -1,62 +1,92 @@
'use client' 'use client'
import { useState, useActionState } from 'react' import { useState, useActionState, useRef } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { createSprintAction } from '@/actions/sprints' import { createSprintAction } from '@/actions/sprints'
interface StartSprintButtonProps { interface StartSprintButtonProps {
productId: string productId: string
isDemo?: boolean
} }
function SubmitButton() { interface ActionResult {
const { pending } = useFormStatus() success?: boolean
return ( error?: string
<Button type="submit" disabled={pending}> code?: number
{pending ? 'Aanmaken…' : 'Sprint starten'} fieldErrors?: Record<string, string[]>
</Button> sprintId?: string
)
} }
export function StartSprintButton({ productId }: StartSprintButtonProps) { export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter() const router = useRouter()
const [state, formAction] = useActionState( const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
async (_prev: unknown, fd: FormData) => { async (_prev, fd) => {
const result = await createSprintAction(_prev, fd) const result = await createSprintAction(_prev, fd) as ActionResult
if (result.success) { if (result?.success) {
setOpen(false) setOpen(false)
setDirty(false)
router.push(`/products/${productId}/sprint`) router.push(`/products/${productId}/sprint`)
} else if (result?.code !== 422 && result?.error) {
// Toast handled by caller; here we just keep the form open
} }
return result return result
}, },
undefined undefined,
) )
const globalError = typeof state?.error === 'string' ? state.error : undefined const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0]
const globalError = state?.code !== 422 ? state?.error : undefined
const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false))
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return ( return (
<> <>
<Button size="sm" onClick={() => setOpen(true)}> <DemoTooltip show={isDemo}>
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo}>
Sprint starten Sprint starten
</Button> </Button>
</DemoTooltip>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
<DialogContent className="sm:max-w-md"> <DialogContent
<DialogHeader> showCloseButton={false}
<DialogTitle>Nieuwe Sprint starten</DialogTitle> onKeyDown={handleKeyDown}
</DialogHeader> className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle>
</div>
<form action={formAction} className="space-y-4 p-1"> <form
ref={formRef}
id="start-sprint-form"
action={formAction}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<input type="hidden" name="productId" value={productId} /> <input type="hidden" name="productId" value={productId} />
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -69,9 +99,11 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
rows={3} rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?" placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus autoFocus
aria-invalid={!!fieldError('sprint_goal')}
className={fieldError('sprint_goal') ? 'border-error' : ''}
/> />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).sprint_goal && ( {fieldError('sprint_goal') && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).sprint_goal[0]}</p> <p className="text-xs text-error">{fieldError('sprint_goal')}</p>
)} )}
</div> </div>
@ -79,15 +111,15 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label> <label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> <input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && ( {fieldError('start_date') && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p> <p className="text-xs text-error">{fieldError('start_date')}</p>
)} )}
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label> <label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> <input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && ( {fieldError('end_date') && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p> <p className="text-xs text-error">{fieldError('end_date')}</p>
)} )}
</div> </div>
</div> </div>
@ -97,16 +129,22 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
{globalError} {globalError}
</div> </div>
)} )}
</form>
<div className="flex gap-2 justify-end"> <div className={entityDialogFooterClasses}>
<Button type="button" variant="ghost" onClick={() => setOpen(false)}> <div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren Annuleren
</Button> </Button>
<SubmitButton /> <Button type="submit" form="start-sprint-form" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
</div>
</div> </div>
</form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</> </>
) )
} }

View file

@ -2,7 +2,7 @@
# Documentation Index # Documentation Index
Auto-generated on 2026-05-03 from front-matter and headings. Auto-generated on 2026-05-04 from front-matter and headings.
## Architecture Decision Records ## Architecture Decision Records
@ -23,8 +23,13 @@ Auto-generated on 2026-05-03 from front-matter and headings.
| Title | Status | Updated | | Title | Status | Updated |
|---|---|---| |---|---|---|
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-03 | | [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-04 |
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 | | [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 |
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 |
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-04 |
| [TaskDetailDialog Profiel](./specs/dialogs/task-detail.md) | active | 2026-05-04 |
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 | | [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |
| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 | | [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 |
| [DevPlanner — User Personas](./specs/personas.md) | active | 2026-05-03 | | [DevPlanner — User Personas](./specs/personas.md) | active | 2026-05-03 |

View file

@ -0,0 +1,68 @@
---
title: "AnswerModal Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# AnswerModal Profiel
> Volgt `docs/patterns/dialog.md`. Beschrijft alleen de Q&A-specifieke afwijkingen.
## Doel
Een Claude-agent vraagt tijdens een lopende job een verduidelijking aan de gebruiker. De vraag verschijnt als notification (bell + SSE event). Klik op de notification opent deze dialog waarin de gebruiker antwoordt — vrij tekst (max `ANSWER_MAX_CHARS`) of een keuze uit `options` als die meegegeven is.
## Velden
| Veld | Type | Mode | Validatie |
|---|---|---|---|
| `answer` | string | both | min 1, max `ANSWER_MAX_CHARS` (4000), trim |
`questionId` komt uit de prop `question.id`, niet uit het formulier.
## Schema
`lib/schemas/question-answer.ts`:
- `answerQuestionSchema` — gedeeld door form + `answerQuestion` action
- `ANSWER_MAX_CHARS` constant — gebruikt door textarea + char-counter
## URL- of state-pattern
- Gekozen: **state-based**`question: NotificationQuestion | null`-prop uit `notifications-sheet`.
## Server action
- `answerQuestion(questionId, answer)` in `actions/questions.ts`
- Result-shape: `{ ok: true } | { ok: false; error: string }`
- Demo-policy: `session.isDemo`-check in actie blokkeert demo-writes (laag 2). Laag 3 wordt verzorgd door `<DemoTooltip>` rond submit-knop en disabled-state op de textarea.
- Atomic transition met `updateMany` voorkomt double-submit races.
## Layout
Gebruikt `entityDialogContentClasses` (§4 spec). Body bevat naast de textarea ook de gestelde vraag (read-only block) en een link naar de bijbehorende sprint. Geen klassieke form-tag — de Textarea is een controlled component.
## Speciale gedragingen
### Multiple-choice mode
Als `question.options` niet leeg is, wordt de textarea vervangen door een lijst van knoppen. Klikken op een knop submit direct met die waarde. De submit-knop in de footer wordt dan verborgen (alleen Annuleren blijft).
### Optimistic remove
Na succesvolle submit wordt de vraag direct uit `useNotificationsStore` verwijderd. De SSE-event komt later met dezelfde verwijdering — voorkomt extra render.
### Dirty-tracking
Single-field form: dirty = `answer.trim().length > 0`. Esc/backdrop/Cancel met dirty-state opent de standaard guard.
## Foutcodes
Action geeft alleen `{ ok, error: string }` terug — geen 422-fieldErrors omdat het een single-field form is. Errors worden via toast getoond. Validatie (`min 1`, `max 4000`) wordt UI-side voorkomen via maxLength + submit-disable.
## Bewust NIET in v1
- ❌ **Markdown rendering** — antwoord wordt als plain text doorgegeven; Claude leest 'm direct als context.
- ❌ **Cmd/Ctrl+Enter shortcut** — werkt wél voor de textarea-mode (via `useDialogSubmitShortcut`); voor multiple-choice mode is er geen submit om te triggeren.
- ❌ **Bulk-answer** — één vraag tegelijk per dialog.

View file

@ -0,0 +1,48 @@
---
title: "BatchEnqueueBlockerDialog Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# BatchEnqueueBlockerDialog Profiel
> Volgt `docs/patterns/dialog.md`. Dit is een **informational / confirm-dialog** zonder eigen entiteit — geen schema, geen demo-policy, geen server actions.
## Doel
Wanneer de gebruiker in solo-mode "stuur volgende N taken naar Claude" probeert maar er een blokkade voor de N-de taak ligt (een PBI op `blocked` of een taak op `review`), stopt de UI het in deze dialog en biedt aan om alleen de taken **vóór** de blokkade te queuen.
## Modus
Confirm-dialog. Geen create/edit/detail. Geen form.
## Props
```ts
{
open: boolean
onOpenChange: (v: boolean) => void
prefixCount: number // hoeveel taken vóór de blokkade liggen
blockerReason: 'task-review' | 'pbi-blocked'
blockerLabel: string // titel van de blokkerende PBI/taak
onConfirm: () => void // alleen taken vóór de blokkade queuen
onCancel: () => void // helemaal annuleren
}
```
## URL- of state-pattern
- Gekozen: **state-based** — gerendeerd door `solo-board` met een `BatchEnqueueState | null`-prop.
## Layout
Gebruikt `entityDialogContentClasses` voor responsive sizing.
## Bewust NIET in v1
- ❌ **Geen demo-policy** — de dialog schrijft niet zelf; demo-blokkering vindt plaats wanneer `onConfirm` de daadwerkelijke `enqueueClaudeJobAction` aanroept (laag 2 demo-check zit daar).
- ❌ **Geen schema** — geen veldwaarden in/uit; alleen confirm/cancel.
- ❌ **Geen dirty-close-guard** — geen state om dirty te raken.
- ❌ **Geen Cmd/Ctrl+Enter** — niet zinvol voor confirm-only.

View file

@ -3,7 +3,7 @@ title: "PbiDialog Profiel"
status: active status: active
audience: [ai-agent, contributor] audience: [ai-agent, contributor]
language: nl language: nl
last_updated: 2026-05-03 last_updated: 2026-05-04
--- ---
# PbiDialog Profiel # PbiDialog Profiel
@ -74,7 +74,11 @@ Beide acties moeten de drielaagse demo-policy volgen (zie § Bekende gaps).
### Form-state via `useActionState` ### Form-state via `useActionState`
PbiDialog gebruikt het `useActionState` + `useFormStatus`-patroon (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors worden gemapt via een lokale `fieldError(field)`-helper die `result.error` als `Record<string, string[]>` interpreteert wanneer 'm geen string is. PbiDialog gebruikt `useActionState` (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors komen uit het action-result als `result.fieldErrors: Record<string, string[]>` met `result.code === 422`; een lokale `fieldError(field)`-helper levert het eerste bericht op.
### Dirty-tracking handmatig
Omdat we geen `react-hook-form` gebruiken, zetten we `dirty` op `true` bij de eerste `onChange` op het form (en bij wijzigingen van de hidden-state-velden `priority`/`status`). De useDirtyCloseGuard hook gebruikt dit boolean om Esc/Cancel-sluiting te beschermen.
### `key`-prop op `<form>` ### `key`-prop op `<form>`
@ -93,16 +97,10 @@ Het `<form>`-element heeft `key={isEdit ? pbi!.id : 'create'}` — dit reset nat
--- ---
## Bekende gaps t.o.v. generieke spec ## Bewust NIET in v1 (PBI-specifiek)
> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie). - ❌ **Geen delete-knop / `deletePbiAction`-trigger vanuit deze dialog** — PBI's worden niet vernietigend verwijderd vanuit de UI; wijzig de status naar `done` of archiveer via een ander mechanisme. `deletePbiAction` bestaat in de codebase voor server-side cleanup maar wordt niet vanuit deze dialog aangeroepen.
- ❌ **Geen char-counter / markdown-hint** op description — PBI-descriptions zijn doorgaans kort en richtinggevend; auto-grow en markdown-rendering horen op StoryDialog/TaskDialog.
- ❌ **Geen `<DemoTooltip>`** rond submit-knop — laag 3 van de drielaagse demo-policy ontbreekt voor PBI-create/update. Dat betekent dat een demo-user de knop kan klikken; de server action blokkeert nog steeds (laag 2), maar de UX is suboptimaal.
- ❌ **Geen delete-knop / `deletePbiAction`** — alleen create + update. Of dat bewust is (PBI's worden nooit verwijderd, alleen status veranderd) of een gat, moet expliciet worden besloten en in dit profiel vastgelegd.
- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`.
- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop.
- ❌ **Geen char-counter / markdown-hint** op description — bewust weggelaten omdat PBI-descriptions kort zijn, maar verdient expliciete bevestiging.
- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-md` i.p.v. de `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4.
--- ---

View file

@ -0,0 +1,59 @@
---
title: "ProductDialog Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# ProductDialog Profiel
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de afwijkingen en entity-specifieke keuzes.
## Velden
| Veld | Type | Mode | Validatie |
|---|---|---|---|
| `name` | string | both | min 1, max 200 |
| `code` | string? | both | max 20, alleen `[a-zA-Z0-9._-]`; uniek per gebruiker |
| `description` | string? | both | max 4000, markdown vrij |
| `repo_url` | string? \| null | both | https URL, moet beginnen met `https://github.com/` |
| `definition_of_done` | string? | both | max 4000, vrije tekst |
| `auto_pr` | boolean | both | default `false` |
## URL- of state-pattern
- Gekozen: **state-based** (§11.2)
- Reden: dialog leeft binnen één parent-component (`ProductList` op `/dashboard` en de product-actions-bar op `/products/[id]`); deep-linking is niet vereist
- Open-state komt uit `ProductList` (lijst-context) of `EditProductButton` (single-item context)
## Status-veld
N.v.t. — Product heeft geen status-enum. `archived` is een boolean buiten dit dialog (eigen archive-flow).
## Server actions
- `createProductAction(data)` in `actions/products.ts` — context-arg via `revalidatePath('/products')` + `revalidatePath('/dashboard')`
- `updateProductAction(id, data)` in `actions/products.ts` — context-arg via `revalidatePath('/products/${id}')` + `revalidatePath('/dashboard')` + `pg_notify('product_updated')`
- Beide hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter` voor update
- Resultaat-shape: `{ success: true, productId? }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }`
## Foutcodes
| Code | Wanneer | UI |
|---|---|---|
| 422 | zod-validatie of code-uniqueness | `fieldErrors``form.setError`, geen toast, focus naar eerste error-veld |
| 403 | niet ingelogd, demo-modus, of geen toegang tot product | toast met message |
| 500 | onverwacht | huidige behandeling: error wordt door React opgevangen — laat de form open |
## Speciale gedragingen
- **Custom switch voor `auto_pr`**: native `<button role="switch">` met MD3-kleur-tokens (geen aparte primitive in v1; zou gepromoot moeten worden naar `components/shared/switch.tsx` zodra elders nodig).
- **Code-uniqueness server-side**: bij conflict wordt `fieldErrors.code` gezet; veld krijgt rode rand.
- **`useProductsStore` updates**: na succesvolle save wordt de in-memory store synchroon bijgewerkt zodat de productlijst onmiddellijk reageert (lokaal-first).
## Bewust NIET in v1
- Verwijderen vanuit deze dialog (loopt via `archiveProductAction` op een andere knop)
- Bulk edit
- Members beheren (eigen scherm op `/products/[id]/settings`)

View file

@ -0,0 +1,72 @@
---
title: "Sprint Dialogs Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# Sprint Dialogs Profiel
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de Sprint-specifieke afwijkingen en keuzes.
Sprint heeft drie dialog-flows verspreid over twee componenten:
| Flow | Locatie | Mode |
|---|---|---|
| Sprint starten | `components/sprint/start-sprint-button.tsx` | create |
| Datums bewerken | `components/sprint/sprint-header.tsx` (inline) | edit |
| Sprint afronden | `components/sprint/sprint-header.tsx` (inline) | actie-bevestiging |
Daarnaast bestaat een **inline edit-form** voor de Sprint Goal in `sprint-header.tsx` — dat is geen dialog (geen modale overlay) maar een toggleable form-row.
## Velden
### Create / Edit dates
| Veld | Type | Validatie |
|---|---|---|
| `sprint_goal` | string (alleen create) | min 1, max 500 |
| `start_date` | date \| null | optioneel; `end_date >= start_date` |
| `end_date` | date \| null | optioneel; `end_date >= start_date` |
### Complete sprint
Geen form-velden. Per story-rij in de sprint kiest de gebruiker `'DONE'` of `'OPEN'` (default `'OPEN'`). De decisions-map wordt direct aan `completeSprintAction` doorgegeven.
## URL- of state-pattern
- Gekozen: **state-based** (§11.2)
- Reden: alle dialogen zijn local-state in hun parent-component (button of header). Sprint heeft geen deep-link-bare detail-pagina voor zijn dialogen.
## Server actions
- `createSprintAction(_prev, fd)``actions/sprints.ts` — revalidate `/products/${productId}`
- `updateSprintDatesAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
- `updateSprintGoalAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint`
- `completeSprintAction(sprintId, decisions)` — niet form-based; directe argumenten
- Alle hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter`/`getAccessibleProduct` voor scope
- Resultaat-shape: `{ success: true, ... }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }`
## Foutcodes
| Code | Wanneer | UI |
|---|---|---|
| 422 | zod-validatie of date-order constraint | `fieldErrors` onder de velden, geen toast |
| 403 | niet ingelogd, demo-modus, of geen toegang | toast met message |
## Schema
`lib/schemas/sprint.ts` exporteert:
- `createSprintSchema` — productId, sprint_goal, start_date, end_date
- `updateSprintDatesSchema` — id, start_date, end_date
- `updateSprintGoalSchema` — id, sprint_goal
- `validateDateOrder` — refinement gebruikt door beide date-schemas
Alle drie de actions importeren hier; geen inline schemas meer.
## Bewust NIET in v1
- ❌ **Eén consolideerde `SprintDialog`-component** met `mode: 'create' | 'edit-dates' | 'complete'` — overwogen tijdens story 5 maar niet uitgevoerd; de dialogen leven natuurlijker in hun parent-component (button / header) en worden niet hergebruikt elders. Indien een vierde sprint-dialog ontstaat, hernieuw deze afweging.
- ❌ Bewerken van de Sprint Goal vanuit deze dialogen — gebeurt via een inline-form in `sprint-header.tsx` (toggleable, geen modal)
- ❌ Sprint-templates / kopiëren van vorige sprint

View file

@ -3,7 +3,7 @@ title: "StoryDialog Profiel"
status: active status: active
audience: [ai-agent, contributor] audience: [ai-agent, contributor]
language: nl language: nl
last_updated: 2026-05-03 last_updated: 2026-05-04
--- ---
# StoryDialog Profiel # StoryDialog Profiel
@ -104,19 +104,17 @@ In edit-mode wordt onder het form een `<StoryLog>`-paneel getoond met de chronol
Dit is een **read-only side-panel** en valt binnen de uitzondering die de generieke spec § 13 maakt voor `<StoryLog>`-style activity-rendering. Dit is een **read-only side-panel** en valt binnen de uitzondering die de generieke spec § 13 maakt voor `<StoryLog>`-style activity-rendering.
### Delete-flow (afwijking van generieke spec) ### Delete-flow
Generieke spec § 10.4 vereist een **`AlertDialog`** voor delete-confirmatie. StoryDialog gebruikt in plaats daarvan een **inline-confirm** in dezelfde footer-rij: Volgt generieke spec § 10.4: klik op "Verwijderen" opent een `AlertDialog` ("Story verwijderen — bijbehorende taken worden ook verwijderd"). Bevestigen roept `deleteStoryAction` aan.
```
[ Weet je het zeker? Taken worden ook verwijderd. [Verwijderen] [Annuleren] ]
```
Een `AlertDialog` zou een tweede modale laag toevoegen die in deze context onhandig voelt (de dialog zelf is al een interruptive overlay). De inline-confirm is een **bewuste afwijking** van de generieke spec.
### Form-state via `useActionState` ### Form-state via `useActionState`
Net als PbiDialog gebruikt StoryDialog `useActionState` + `useFormStatus`, niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Net als PbiDialog gebruikt StoryDialog `useActionState`, niet `react-hook-form`. Pending-state komt uit de derde return-waarde (`useActionState[2]`). Dit is een toegestaan alternatief volgens de generieke spec § 2.
### Dirty-tracking handmatig
Geen `react-hook-form`, dus `dirty` wordt op `true` gezet bij de eerste `onChange` op het form en bij wijzigingen van de hidden-state (`priority`). De `useDirtyCloseGuard` hook gebruikt deze boolean om Esc/Cancel/backdrop te beschermen.
### `key`-prop op `<form>` ### `key`-prop op `<form>`
@ -132,16 +130,10 @@ Het `<form>` heeft `key={isEdit ? story!.id : 'create'}` — reset native form-s
--- ---
## Bekende gaps t.o.v. generieke spec ## Bewuste afwijkingen van generieke spec
> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie). - ⚠️ **Header-layout** met meerdere badges wijkt af van de sobere header in § 4. Bewuste keuze — story-context (priority + status) wil je direct zichtbaar bij record-wisselen.
- ❌ **Geen char-counter / markdown-hint** op description / acceptance_criteria — bewust weggelaten omdat stories meestal één zin lang zijn.
- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`.
- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop.
- ❌ **Geen char-counter / markdown-hint** op description / acceptance_criteria — bewust weggelaten, maar verdient expliciete bevestiging als design-keuze.
- ⚠️ **Inline-delete-confirm** in plaats van AlertDialog (zie § Speciale gedragingen). Bewuste afwijking; de generieke spec mag deze variant expliciet toestaan, of dit profile moet als precedent gelden voor toekomstige dialogen.
- ⚠️ **Header-layout** met meerdere badges wijkt af van de sobere header in § 4. Bewuste afwijking — context-zwaar bij story-wisselen.
- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-lg` met eigen `max-h-[90vh]` + `flex flex-col` i.p.v. de exacte `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4.
--- ---

View file

@ -0,0 +1,65 @@
---
title: "TaskDetailDialog Profiel"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-04
---
# TaskDetailDialog Profiel
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de Solo-specifieke afwijkingen.
> **Niet te verwarren met `TaskDialog`** (`app/_components/tasks/task-dialog.tsx`) — dat is de classic create/edit-dialog voor backlog-taken. **`TaskDetailDialog`** is een solo-board-specifieke detail+edit-overlay die een lopende taak laat zien terwijl de Claude-agent eraan werkt.
## Doel
Vanuit het solo-board kan de gebruiker op een task-card klikken om:
- Het implementation_plan te lezen / bewerken (markdown, blur-save)
- Verify-instellingen te wijzigen (`verify_only`, `verify_required`)
- Claude-job-status en branch-link te zien
- De huidige taak naar Claude te sturen / te annuleren
## Velden
| Veld | Type | Validatie |
|---|---|---|
| `implementation_plan` | string \| null | max 10000, markdown, blur-save |
| `verify_only` | boolean | toggle, direct opgeslagen |
| `verify_required` | enum `'ALIGNED' \| 'ALIGNED_OR_PARTIAL' \| 'ANY'` | radio, direct opgeslagen |
## URL- of state-pattern
- Gekozen: **state-based**`task: SoloTask | null` prop uit `solo-board`. `null` = dialog gesloten.
- Reden: solo-board is one-page; de detail-dialog is altijd in context.
## Persistence
**Geen klassiek form-submit.** Wijzigingen schrijven via `fetch('/api/tasks/:id', { method: 'PATCH' })` (route handler), getriggerd door:
- Plan-textarea: blur of debounced auto-save
- Verify-toggles: direct bij click
Dit valt buiten de standaard "Server Action met form" flow van `docs/patterns/dialog.md` § 7. Reden: het zijn fine-grained edits van een lopende taak, geen save-dan-sluit-flow.
## Drielaagse demo-policy
- **Laag 1 (proxy.ts):** `/api/tasks/[id]`-route is via `apiAuth`-helper beschermd; demo-write zou geblokkeerd moeten worden in de route handler zelf
- **Laag 2 (route handler):** `session.isDemo`-check in `app/api/tasks/[id]/route.ts` (PATCH) — verifieer dat dit aanwezig is
- **Laag 3 (UI):** `<DemoTooltip show={isDemo}>` rond plan-textarea en verify-toggles; `readOnly` resp `disabled`-state op de controls
## Layout
Gebruikt `entityDialogContentClasses` voor responsive sizing (§4 spec). Body-layout intern is custom (gegroepeerde secties in plaats van form-fields) want het is een hybride detail+edit-view, niet een klassiek form.
## Bewust NIET in v1
- ❌ **Klassiek save-dan-sluit-form** — blur-save is bewust gekozen omdat de gebruiker tussendoor de plan-tekst herziet terwijl Claude bezig is.
- ❌ **Dirty-close-guard** — niet relevant zonder klassiek submit-form; wijzigingen worden direct gepersisteerd.
- ❌ **Cmd/Ctrl+Enter shortcut** — geen submit, dus n.v.t.
- ❌ **422-fieldErrors** — fine-grained PATCH-route geeft simpele 200/400/403; geen veldgewijze rendering nodig in deze UX.
## Gerelateerde bestanden
- `components/solo/task-detail-dialog.tsx` — implementatie
- `app/api/tasks/[id]/route.ts` — PATCH-handler
- `stores/solo-store.ts` — client-state

26
lib/schemas/pbi.ts Normal file
View file

@ -0,0 +1,26 @@
import { z } from 'zod'
import { MAX_CODE_LENGTH } from '@/lib/code'
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
const statusField = z.enum(['ready', 'blocked', 'done']).optional()
export 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,
})
export const updatePbiSchema = z.object({
id: 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,
})
export type CreatePbiInput = z.infer<typeof createPbiSchema>
export type UpdatePbiInput = z.infer<typeof updatePbiSchema>

18
lib/schemas/product.ts Normal file
View file

@ -0,0 +1,18 @@
import { z } from 'zod'
export const productSchema = z.object({
name: z.string().trim().min(1, 'Naam is verplicht').max(200, 'Maximaal 200 tekens'),
code: z.string().trim().max(20, 'Maximaal 20 tekens').optional(),
description: z.string().max(4000, 'Maximaal 4000 tekens').optional(),
repo_url: z
.string()
.url('Voer een geldige URL in (inclusief https://)')
.regex(/^https:\/\/github\.com\//, 'Alleen GitHub-URLs worden ondersteund')
.optional()
.nullable()
.or(z.literal('')),
definition_of_done: z.string().max(4000, 'Maximaal 4000 tekens').optional(),
auto_pr: z.boolean(),
})
export type ProductInput = z.infer<typeof productSchema>

View file

@ -0,0 +1,10 @@
import { z } from 'zod'
export const ANSWER_MAX_CHARS = 4000
export const answerQuestionSchema = z.object({
questionId: z.string().cuid(),
answer: z.string().min(1, 'Antwoord mag niet leeg zijn').max(ANSWER_MAX_CHARS),
})
export type AnswerQuestionInput = z.infer<typeof answerQuestionSchema>

38
lib/schemas/sprint.ts Normal file
View file

@ -0,0 +1,38 @@
import { z } from 'zod'
const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null))
export function validateDateOrder(
data: { start_date: Date | null; end_date: Date | null },
ctx: z.RefinementCtx,
) {
if (data.start_date && data.end_date && data.end_date < data.start_date) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' })
}
}
export const createSprintSchema = z
.object({
productId: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
start_date: dateField,
end_date: dateField,
})
.superRefine(validateDateOrder)
export const updateSprintDatesSchema = z
.object({
id: z.string(),
start_date: dateField,
end_date: dateField,
})
.superRefine(validateDateOrder)
export const updateSprintGoalSchema = z.object({
id: z.string(),
sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500),
})
export type CreateSprintInput = z.infer<typeof createSprintSchema>
export type UpdateSprintDatesInput = z.infer<typeof updateSprintDatesSchema>
export type UpdateSprintGoalInput = z.infer<typeof updateSprintGoalSchema>

26
lib/schemas/story.ts Normal file
View file

@ -0,0 +1,26 @@
import { z } from 'zod'
import { MAX_CODE_LENGTH } from '@/lib/code'
const codeField = z.string().max(MAX_CODE_LENGTH).optional()
export const createStorySchema = z.object({
pbiId: z.string(),
productId: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
})
export const updateStorySchema = z.object({
id: z.string(),
code: codeField,
title: z.string().min(1, 'Titel is verplicht').max(200),
description: z.string().max(2000).optional(),
acceptance_criteria: z.string().max(2000).optional(),
priority: z.coerce.number().int().min(1).max(4),
})
export type CreateStoryInput = z.infer<typeof createStorySchema>
export type UpdateStoryInput = z.infer<typeof updateStorySchema>