feat(pbi-dialog): conform aan dialog-pattern + DemoTooltip + dirty-guard

Story 3 van PBI "Alle dialogen conform docs/patterns/dialog.md".

- lib/schemas/pbi.ts — gedeeld zod-schema (createPbiSchema/updatePbiSchema)
- actions/pbis.ts — returnen nu code+fieldErrors (422) en code: 403 voor
  auth/demo errors
- PbiDialog adopt useDirtyCloseGuard, useDialogSubmitShortcut,
  entityDialog* layout-classes; submit-knop + Annuleren in DemoTooltip
- isDemo-prop toegevoegd, pbi-list geeft 'm door
- docs/specs/dialogs/pbi.md — "Bekende gaps" weggewerkt; alleen bewuste
  uitsluitingen blijven

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 07:23:14 +02:00
parent 03a248b0fb
commit 97dc4ee553
6 changed files with 231 additions and 154 deletions

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

@ -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,84 +110,112 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
} }
}, [state]) }, [state])
const closeGuard = useDirtyCloseGuard(dirty, onClose)
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return ( return (
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}> <>
<DialogContent className="sm:max-w-md"> <Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogHeader> <DialogContent
<DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle> showCloseButton={false}
</DialogHeader> onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}
</DialogTitle>
</div>
<form key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4"> <form
{isEdit && <input type="hidden" name="id" value={pbi!.id} />} ref={formRef}
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />} id="pbi-form"
<input type="hidden" name="priority" value={priority} /> key={isEdit ? pbi!.id : 'create'}
<input type="hidden" name="status" value={status} /> action={isEdit ? updateAction : createAction}
onChange={() => setDirty(true)}
className={entityDialogBodyClasses}
>
{isEdit && <input type="hidden" name="id" value={pbi!.id} />}
{!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />}
<input type="hidden" name="priority" value={priority} />
<input type="hidden" name="status" value={status} />
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="grid gap-1.5">
<label htmlFor="pbi-code" className="text-sm font-medium">Code</label>
<Input
id="pbi-code"
name="code"
defaultValue={pbi?.code ?? ''}
placeholder={isEdit ? '' : 'auto'}
maxLength={30}
disabled={isDemo}
aria-invalid={!!fieldError('code')}
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
/>
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div>
<div className="grid gap-1.5">
<label htmlFor="pbi-title" className="text-sm font-medium">Titel <span className="text-error">*</span></label>
<Input
id="pbi-title"
ref={titleRef}
name="title"
defaultValue={pbi?.title ?? ''}
placeholder="PBI-titel…"
required
maxLength={200}
disabled={isDemo}
aria-invalid={!!fieldError('title')}
className={fieldError('title') ? 'border-error' : ''}
/>
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} />
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Status</label>
<PbiStatusSelect value={status} onChange={(v) => { setStatus(v); setDirty(true) }} />
</div>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-3">
<div className="grid gap-1.5"> <div className="grid gap-1.5">
<label htmlFor="pbi-code" className="text-sm font-medium">Code</label> <label htmlFor="pbi-description" className="text-sm font-medium">
<Input Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
id="pbi-code" </label>
name="code" <Textarea
defaultValue={pbi?.code ?? ''} id="pbi-description"
placeholder={isEdit ? '' : 'auto'} name="description"
maxLength={30} defaultValue={pbi?.description ?? ''}
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'} placeholder="Korte omschrijving van het PBI…"
rows={3}
maxLength={2000}
disabled={isDemo}
className="resize-none"
/> />
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div> </div>
<div className="grid gap-1.5"> </form>
<label htmlFor="pbi-title" className="text-sm font-medium">Titel</label>
<Input <div className={entityDialogFooterClasses}>
id="pbi-title" <div className="flex items-center justify-end gap-2">
ref={titleRef} <Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
name="title" Annuleren
defaultValue={pbi?.title ?? ''} </Button>
placeholder="PBI-titel…" <DemoTooltip show={isDemo}>
required <Button type="submit" form="pbi-form" disabled={pending || isDemo}>
maxLength={200} {pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
className={fieldError('title') ? 'border-error' : ''} </Button>
/> </DemoTooltip>
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</div> </div>
</div> </div>
</DialogContent>
<div className="grid grid-cols-2 gap-3"> </Dialog>
<div className="grid gap-1.5"> <DirtyCloseGuardDialog guard={closeGuard} />
<label className="text-sm font-medium">Prioriteit</label> </>
<PrioritySelect value={priority} onChange={setPriority} />
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Status</label>
<PbiStatusSelect value={status} onChange={setStatus} />
</div>
</div>
<div className="grid gap-1.5">
<label htmlFor="pbi-description" className="text-sm font-medium">
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="pbi-description"
name="description"
defaultValue={pbi?.description ?? ''}
placeholder="Korte omschrijving van het PBI…"
rows={3}
maxLength={2000}
className="resize-none"
/>
</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>
</DialogContent>
</Dialog>
) )
} }

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

@ -23,7 +23,7 @@ Auto-generated on 2026-05-04 from front-matter and headings.
| Title | Status | Updated | | Title | Status | Updated |
|---|---|---| |---|---|---|
| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-03 | | [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 |
| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 | | [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 |
| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 | | [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 |
| [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 | | [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 |

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.
--- ---

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>