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

@ -2,21 +2,29 @@
import { useEffect, useRef, useState } from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { PrioritySelect } from '@/components/shared/priority-select'
import { PbiStatusSelect } from '@/components/shared/pbi-status-select'
import { 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 type { PbiStatusApi } from '@/lib/task-status'
@ -36,18 +44,18 @@ export type PbiDialogState = CreateState | EditState
interface PbiDialogProps {
state: PbiDialogState | null
onClose: () => void
isDemo?: boolean
}
function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? '…' : label}
</Button>
)
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
pbi?: unknown
}
export function PbiDialog({ state, onClose }: PbiDialogProps) {
export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) {
const isEdit = state?.mode === 'edit'
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 [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(() => {
if (state) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2))
setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready')
setDirty(false)
}
}, [state, isEdit])
const [createState, createAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createPbiAction(_prev, fd)
const [createState, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await createPbiAction(_prev, fd) as ActionResult
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
},
undefined
undefined,
)
const [updateState, updateAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updatePbiAction(_prev, fd)
const [updateState, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updatePbiAction(_prev, fd) as ActionResult
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
},
undefined
undefined,
)
const pending = isEdit ? updatePending : createPending
const activeState = isEdit ? updateState : createState
const error = typeof activeState?.error === 'string' ? activeState.error : null
const fieldError = (field: string) => {
const err = activeState?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0]
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => {
@ -102,84 +110,112 @@ export function PbiDialog({ state, onClose }: PbiDialogProps) {
}
}, [state])
const closeGuard = useDirtyCloseGuard(dirty, onClose)
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
return (
<Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle>
</DialogHeader>
<>
<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 key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4">
{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} />
<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="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">
<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}
className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'}
<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}
disabled={isDemo}
className="resize-none"
/>
{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</label>
<Input
id="pbi-title"
ref={titleRef}
name="title"
defaultValue={pbi?.title ?? ''}
placeholder="PBI-titel…"
required
maxLength={200}
className={fieldError('title') ? 'border-error' : ''}
/>
{fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>}
</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>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<label className="text-sm font-medium">Prioriteit</label>
<PrioritySelect value={priority} onChange={setPriority} />
</div>
<div className="grid gap-1.5">
<label className="text-sm font-medium">Status</label>
<PbiStatusSelect value={status} onChange={setStatus} />
</div>
</div>
<div className="grid gap-1.5">
<label htmlFor="pbi-description" className="text-sm font-medium">
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>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

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

View file

@ -1,17 +1,24 @@
'use client'
import { useEffect, useRef, useState, useTransition } from 'react'
import { Markdown } from '@/components/markdown'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { Markdown } from '@/components/markdown'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} 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 { Input } from '@/components/ui/input'
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 { StoryLog } from '@/components/shared/story-log'
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 { cn } from '@/lib/utils'
import type { Story } from './story-panel'
@ -33,6 +50,14 @@ interface StoryDialogProps {
isDemo?: boolean
}
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
story?: unknown
}
const STATUS_COLORS: Record<string, string> = {
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',
@ -44,15 +69,6 @@ const STATUS_LABELS: Record<string, string> = {
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) {
const isEdit = state?.mode === 'edit'
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 [isDeleting, startDeleteTransition] = useTransition()
const [logs, setLogs] = useState<Awaited<ReturnType<typeof getStoryLogsAction>> | null>(null)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
useEffect(() => {
if (!state) return
// eslint-disable-next-line react-hooks/set-state-in-effect
setConfirmDelete(false)
setDirty(false)
if (state.mode === 'edit') {
setPriority(state.story.priority)
setLogs(null)
getStoryLogsAction(state.story.id).then(setLogs)
} else {
setPriority(state.defaultPriority ?? 2)
}
}, [state])
const [createResult, createAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createStoryAction(_prev, fd)
const [createResult, createAction, createPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await createStoryAction(_prev, fd) as ActionResult
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
},
undefined
undefined,
)
const [updateResult, updateAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateStoryAction(_prev, fd)
const [updateResult, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updateStoryAction(_prev, fd) as ActionResult
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
},
undefined
undefined,
)
const fieldError = (field: string) => {
const result = isEdit ? updateResult : createResult
const err = result?.error
if (!err || typeof err === 'string') return undefined
return (err as Record<string, string[]>)[field]?.[0]
}
const pending = isEdit ? updatePending : createPending
const activeResult = isEdit ? updateResult : createResult
const fieldError = (field: string) => activeResult?.fieldErrors?.[field]?.[0]
function handleDelete() {
if (!story) return
setConfirmDelete(false)
startDeleteTransition(async () => {
const result = await deleteStoryAction(story.id)
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
@ -121,49 +135,61 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
if (state) setTimeout(() => titleRef.current?.focus(), 50)
}, [state])
const closeGuard = useDirtyCloseGuard(dirty, onClose)
const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit())
const showForm = !isDemo || !isEdit
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">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 pr-14">
<div className="flex items-start gap-2">
<DialogTitle className="flex-1">{isEdit ? story!.title : 'Nieuwe story'}</DialogTitle>
{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">
{story!.code}
</span>
<>
<Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className={cn(entityDialogHeaderClasses, 'flex-col items-stretch gap-1')}>
<div className="flex items-start gap-2">
<DialogTitle className="flex-1 text-xl font-semibold">
{isEdit ? story!.title : 'Nieuwe story'}
</DialogTitle>
{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">
{story!.code}
</span>
)}
</div>
{isEdit && (
<div className="flex gap-2">
<Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]}
</Badge>
<Badge className={cn('text-xs border', STATUS_COLORS[story!.status])}>
{STATUS_LABELS[story!.status]}
</Badge>
</div>
)}
</div>
{isEdit && (
<div className="flex gap-2 mt-1">
<Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}>
{PRIORITY_LABELS[priority]}
</Badge>
<Badge className={cn('text-xs border', STATUS_COLORS[story!.status])}>
{STATUS_LABELS[story!.status]}
</Badge>
</div>
)}
</DialogHeader>
<form
key={isEdit ? story!.id : 'create'}
action={isEdit ? updateAction : createAction}
className="flex flex-col min-h-0 flex-1"
>
{isEdit && <input type="hidden" name="id" value={story!.id} />}
{!isEdit && (
<>
<input type="hidden" name="pbiId" value={createState_?.pbiId ?? ''} />
<input type="hidden" name="productId" value={createState_?.productId ?? ''} />
</>
)}
<input type="hidden" name="priority" value={priority} />
<form
ref={formRef}
id="story-form"
key={isEdit ? story!.id : 'create'}
action={isEdit ? updateAction : createAction}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto"
>
{isEdit && <input type="hidden" name="id" value={story!.id} />}
{!isEdit && (
<>
<input type="hidden" name="pbiId" value={createState_?.pbiId ?? ''} />
<input type="hidden" name="productId" value={createState_?.productId ?? ''} />
</>
)}
<input type="hidden" name="priority" value={priority} />
<div className="flex-1 overflow-y-auto">
{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="space-y-1.5">
<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 ?? ''}
placeholder={isEdit ? '' : 'auto'}
maxLength={30}
disabled={isDemo}
aria-invalid={!!fieldError('code')}
className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')}
/>
{fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>}
</div>
<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
ref={titleRef}
name="title"
defaultValue={story?.title ?? ''}
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>}
@ -192,7 +224,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
<div className="space-y-1.5">
<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 className="space-y-1.5">
@ -204,6 +236,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
rows={3}
defaultValue={story?.description ?? ''}
placeholder="Als… wil ik… zodat…"
disabled={isDemo}
className="resize-none"
/>
</div>
@ -217,18 +250,13 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
rows={3}
defaultValue={story?.acceptance_criteria ?? ''}
placeholder="- Gegeven… Als… Dan…"
disabled={isDemo}
className="resize-none"
/>
</div>
{typeof (isEdit ? updateResult?.error : createResult?.error) === 'string' && (
<p className="text-xs text-error">
{String(isEdit ? updateResult?.error : createResult?.error)}
</p>
)}
</div>
) : (
<div className="p-5 space-y-4">
<div className="px-6 py-6 space-y-6">
{story?.description && (
<div>
<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 && (
<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>
{logs && 'logs' in logs && logs.logs ? (
<StoryLog
@ -262,49 +290,59 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps
)}
</div>
)}
</div>
</form>
{isEdit && (
<div className="px-5 py-3 border-t border-border shrink-0">
{!isDemo && confirmDelete ? (
<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>
) : (
<div className={entityDialogFooterClasses}>
<div className="flex items-center justify-between gap-2">
{isEdit ? (
<DemoTooltip show={isDemo}>
<Button
type="button"
variant="ghost"
size="sm"
className="text-error hover:bg-error/10"
disabled={isDemo}
onClick={() => !isDemo && setConfirmDelete(true)}
variant="destructive"
disabled={isDemo || isDeleting || pending}
onClick={() => setConfirmDelete(true)}
>
Story verwijderen
Verwijderen
</Button>
</DemoTooltip>
) : (
<div />
)}
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button type="submit" form="story-form" disabled={pending || isDemo}>
{pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</div>
</div>
)}
<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
</DialogClose>
<DemoTooltip show={isDemo}>
<SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} disabled={isDemo} />
</DemoTooltip>
</div>
</form>
</DialogContent>
</Dialog>
</DialogContent>
</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 { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
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 { 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 {
id: string
name: string
@ -54,8 +51,9 @@ export function ProductDialog(props: Props) {
const [isPending, setIsPending] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
const form = useForm<ProductInput>({
resolver: zodResolver(productSchema),
mode: 'onTouched',
defaultValues: {
name: product?.name ?? '',
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(() => {
if (open) {
form.reset({
@ -81,14 +79,13 @@ export function ProductDialog(props: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, product?.id])
async function onSubmit(values: FormValues) {
if (isDemo) {
toast.error('Niet beschikbaar in demo-modus')
return
}
const closeGuard = useDirtyCloseGuard(form.formState.isDirty, () => onOpenChange(false))
const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)())
async function onSubmit(values: ProductInput) {
setIsPending(true)
try {
const payload = {
const payload: ProductInput = {
name: values.name,
code: values.code || undefined,
description: values.description || undefined,
@ -97,14 +94,29 @@ export function ProductDialog(props: Props) {
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') {
const result = await createProductAction(payload)
if ('error' in result) {
toast.error(result.error)
applyError(result)
return
}
const productId = result.productId
addProduct({
id: result.productId,
id: productId,
name: values.name,
code: values.code ?? null,
description: values.description ?? null,
@ -114,11 +126,11 @@ export function ProductDialog(props: Props) {
})
toast.success('Product aangemaakt')
onOpenChange(false)
props.onSaved?.(result.productId)
props.onSaved?.(productId)
} else {
const result = await updateProductAction(product!.id, payload)
if ('error' in result) {
toast.error(result.error)
applyError(result)
return
}
updateProduct(product!.id, {
@ -141,130 +153,156 @@ export function ProductDialog(props: Props) {
const autoPr = form.watch('auto_pr')
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}</DialogTitle>
</DialogHeader>
<form
id="product-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid gap-4"
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) closeGuard.attemptClose(); else onOpenChange(v) }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<div className="grid gap-1.5">
<label htmlFor="product-name" className="text-sm font-medium">
Naam <span className="text-error">*</span>
</label>
<Input
id="product-name"
autoFocus={mode === 'create'}
disabled={isDemo}
maxLength={200}
{...form.register('name')}
className={form.formState.errors.name ? 'border-error' : ''}
/>
{form.formState.errors.name && (
<p className="text-xs text-error">{form.formState.errors.name.message}</p>
)}
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
{mode === 'edit' ? 'Product bewerken' : 'Nieuw product'}
</DialogTitle>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-code" className="text-sm font-medium">
Code <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-code"
disabled={isDemo}
maxLength={20}
placeholder="korte slug, bv. SCRUM4ME"
className="font-mono text-sm"
{...form.register('code')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-description" className="text-sm font-medium">
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-description"
disabled={isDemo}
rows={3}
maxLength={4000}
className="resize-none"
{...form.register('description')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-repo-url" className="text-sm font-medium">
Repository URL <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-repo-url"
disabled={isDemo}
placeholder="https://github.com/owner/repo"
{...form.register('repo_url')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-dod" className="text-sm font-medium">
Definition of Done <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-dod"
disabled={isDemo}
rows={4}
maxLength={4000}
className="resize-none"
{...form.register('definition_of_done')}
/>
</div>
<div className="flex items-start gap-3">
<button
type="button"
role="switch"
aria-checked={autoPr}
disabled={isDemo}
onClick={() => form.setValue('auto_pr', !autoPr)}
className={cn(
'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',
'disabled:cursor-not-allowed disabled:opacity-50',
autoPr ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm',
'transition-transform duration-200',
autoPr ? 'translate-x-4' : 'translate-x-0',
)}
<form
id="product-form"
onSubmit={form.handleSubmit(onSubmit)}
className={entityDialogBodyClasses}
>
<div className="grid gap-1.5">
<label htmlFor="product-name" className="text-sm font-medium">
Naam <span className="text-error">*</span>
</label>
<Input
id="product-name"
autoFocus={mode === 'create'}
disabled={isDemo}
maxLength={200}
aria-invalid={!!form.formState.errors.name}
{...form.register('name')}
className={form.formState.errors.name ? 'border-error' : ''}
/>
</button>
<div className="grid gap-0.5">
<span className="text-sm font-medium">Automatisch PR aanmaken na voltooide story</span>
<span className="text-xs text-muted-foreground">
Bij elke voltooide story automatisch een PR aanmaken in <code>repo_url</code>
</span>
{form.formState.errors.name && (
<p className="text-xs text-error">{form.formState.errors.name.message}</p>
)}
</div>
<div className="grid gap-1.5">
<label htmlFor="product-code" className="text-sm font-medium">
Code <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-code"
disabled={isDemo}
maxLength={20}
placeholder="korte slug, bv. SCRUM4ME"
aria-invalid={!!form.formState.errors.code}
className={cn('font-mono text-sm', form.formState.errors.code && 'border-error')}
{...form.register('code')}
/>
{form.formState.errors.code && (
<p className="text-xs text-error">{form.formState.errors.code.message}</p>
)}
</div>
<div className="grid gap-1.5">
<label htmlFor="product-description" className="text-sm font-medium">
Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-description"
disabled={isDemo}
rows={3}
maxLength={4000}
className="resize-none"
{...form.register('description')}
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="product-repo-url" className="text-sm font-medium">
Repository URL <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Input
id="product-repo-url"
disabled={isDemo}
placeholder="https://github.com/owner/repo"
aria-invalid={!!form.formState.errors.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 className="grid gap-1.5">
<label htmlFor="product-dod" className="text-sm font-medium">
Definition of Done <span className="text-muted-foreground font-normal">(optioneel)</span>
</label>
<Textarea
id="product-dod"
disabled={isDemo}
rows={4}
maxLength={4000}
className="resize-none"
{...form.register('definition_of_done')}
/>
</div>
<div className="flex items-start gap-3">
<button
type="button"
role="switch"
aria-checked={autoPr}
disabled={isDemo}
onClick={() => form.setValue('auto_pr', !autoPr, { shouldDirty: true })}
className={cn(
'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',
'disabled:cursor-not-allowed disabled:opacity-50',
autoPr ? 'bg-primary' : 'bg-input',
)}
>
<span
className={cn(
'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm',
'transition-transform duration-200',
autoPr ? 'translate-x-4' : 'translate-x-0',
)}
/>
</button>
<div className="grid gap-0.5">
<span className="text-sm font-medium">Automatisch PR aanmaken na voltooide story</span>
<span className="text-xs text-muted-foreground">
Bij elke voltooide story automatisch een PR aanmaken in <code>repo_url</code>
</span>
</div>
</div>
</form>
<div className={entityDialogFooterClasses}>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo}>
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</div>
</div>
</form>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Annuleren
</DialogClose>
<DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo}>
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -2,11 +2,11 @@
// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11).
//
// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de
// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via
// useTransition; bij succes wordt de vraag uit de store verwijderd
// Free-text Textarea (max ANSWER_MAX_CHARS) of multiple-choice via knoppen
// wanneer de vraag `options` heeft. Submit roept answerQuestion-Server-Action
// aan via useTransition; bij succes wordt de vraag uit de store verwijderd
// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit
// disabled met tooltip.
// disabled met DemoTooltip.
import { useState, useTransition } from 'react'
import Link from 'next/link'
@ -16,18 +16,25 @@ import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
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 { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
const MAX_ANSWER_CHARS = 4000
interface AnswerModalProps {
question: NotificationQuestion | null
isDemo: boolean
@ -38,26 +45,23 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
const [answer, setAnswer] = useState('')
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 submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong
function submit(text: string) {
if (!question) return
if (isDemo) {
toast.error('Niet beschikbaar in demo-modus')
return
}
startTransition(async () => {
const res = await answerQuestion(question.id, text)
if (!res.ok) {
toast.error(res.error)
return
}
// Optimistisch verwijderen — SSE-event komt anders later met dezelfde
// remove en kost een extra render
useNotificationsStore.getState().remove(question.id)
toast.success('Antwoord verstuurd')
setAnswer('')
@ -65,93 +69,107 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
})
}
const handleKeyDown = useDialogSubmitShortcut(() => {
if (!submitDisabled) submit(answer)
})
if (!question) return null
return (
<Dialog open={!!question} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Beantwoord Claude</DialogTitle>
<DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span>
{' — '}
{question.story_title}
</DialogDescription>
</DialogHeader>
<Link
href={`/products/${question.product_id}/sprint`}
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
<>
<Dialog open={!!question} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
>
<ExternalLink className="h-3.5 w-3.5" />
<span>Open in Sprint</span>
</Link>
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
{question.question}
</div>
{question.options && question.options.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
<div className="flex flex-col gap-2">
{question.options.map((opt) => (
<Button
key={opt}
type="button"
variant="outline"
className="justify-start"
disabled={isDemo || pending}
onClick={() => submit(opt)}
>
{opt}
</Button>
))}
<div className={entityDialogHeaderClasses}>
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle>
<DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span>
{' — '}
{question.story_title}
</DialogDescription>
</div>
</div>
) : (
<div className="space-y-1">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Typ je antwoord…"
rows={5}
maxLength={MAX_ANSWER_CHARS}
readOnly={isDemo}
aria-label="Antwoord op Claude's vraag"
/>
<div
className={
tooLong
? 'text-error text-right text-xs'
: 'text-muted-foreground text-right text-xs'
}
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<Link
href={`/products/${question.product_id}/sprint`}
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
>
{charsLeft} tekens over
</div>
</div>
)}
<ExternalLink className="h-3.5 w-3.5" />
<span>Open in Sprint</span>
</Link>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={pending}>
Annuleren
</Button>
{(!question.options || question.options.length === 0) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={<span className="inline-flex" />}>
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
{question.question}
</div>
{question.options && question.options.length > 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
<div className="flex flex-col gap-2">
{question.options.map((opt) => (
<DemoTooltip key={opt} show={isDemo}>
<Button
type="button"
variant="outline"
className="justify-start"
disabled={isDemo || pending}
onClick={() => submit(opt)}
>
{opt}
</Button>
</DemoTooltip>
))}
</div>
</div>
) : (
<div className="space-y-1">
<Textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
placeholder="Typ je antwoord…"
rows={5}
maxLength={ANSWER_MAX_CHARS}
disabled={isDemo}
aria-label="Antwoord op Claude's vraag"
/>
<div
className={
tooLong
? 'text-error text-right text-xs'
: 'text-muted-foreground text-right text-xs'
}
>
{charsLeft} tekens over
</div>
</div>
)}
</div>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
{(!question.options || question.options.length === 0) && (
<DemoTooltip show={isDemo}>
<Button
onClick={() => submit(answer)}
disabled={submitDisabled}
>
{pending ? 'Bezig…' : 'Verstuur'}
</Button>
</TooltipTrigger>
{isDemo && (
<TooltipContent>Niet beschikbaar in demo-modus</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</DemoTooltip>
)}
</div>
</div>
</DialogContent>
</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'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
interface BatchEnqueueBlockerDialogProps {
open: boolean
@ -32,12 +37,12 @@ export function BatchEnqueueBlockerDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Blokkade gedetecteerd</DialogTitle>
</DialogHeader>
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Blokkade gedetecteerd</DialogTitle>
</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>
{BLOCKER_REASON_LABELS[blockerReason]}:{' '}
<span className="font-medium">{blockerLabel}</span>.
@ -53,33 +58,35 @@ export function BatchEnqueueBlockerDialog({
)}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-outline-variant">
<Button variant="ghost" onClick={onCancel}>
Annuleer
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span>
<Button
onClick={onConfirm}
disabled={noTasksBeforeBlocker}
>
{prefixCount === 1
? `Stuur ${prefixCount} taak tot aan blokkade`
: `Stuur ${prefixCount} taken tot aan blokkade`}
</Button>
</span>
}
/>
{noTasksBeforeBlocker && (
<TooltipContent side="top" className="text-xs">
Geen taken vóór blokkade
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
Annuleer
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span>
<Button
onClick={onConfirm}
disabled={noTasksBeforeBlocker}
>
{prefixCount === 1
? `Stuur ${prefixCount} taak tot aan blokkade`
: `Stuur ${prefixCount} taken tot aan blokkade`}
</Button>
</span>
}
/>
{noTasksBeforeBlocker && (
<TooltipContent side="top" className="text-xs">
Geen taken vóór blokkade
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
</div>
</DialogContent>
</Dialog>

View file

@ -5,6 +5,7 @@ import Link from 'next/link'
import { toast } from 'sonner'
import { Markdown } from '@/components/markdown'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
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) {
return (
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg">
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
{task && (
<TaskDetailContent
key={task.id}

View file

@ -1,17 +1,25 @@
'use client'
import { useState, useTransition, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useState, useTransition, useActionState, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { toast } from 'sonner'
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 type { SprintStory } from './sprint-backlog'
@ -31,9 +39,11 @@ interface SprintHeaderProps {
sprintStories: SprintStory[]
}
function SaveGoalButton() {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
}
function toDateInputValue(d: Date | null) {
@ -47,33 +57,39 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
const [completeOpen, setCompleteOpen] = useState(false)
const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({})
const [isCompleting, startCompleting] = useTransition()
const [datesDirty, setDatesDirty] = useState(false)
const datesFormRef = useRef<HTMLFormElement>(null)
const [, goalFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintGoalAction(_prev, fd)
const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updateSprintGoalAction(_prev, fd) as ActionResult
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
},
undefined
undefined,
)
const [datesState, datesFormAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateSprintDatesAction(_prev, fd)
if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') }
else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
const [datesState, datesFormAction, datesPending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await updateSprintDatesAction(_prev, fd) as ActionResult
if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') }
else if (result?.code !== 422 && result?.error) toast.error(result.error)
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') {
setDecisions(prev => ({ ...prev, [storyId]: value }))
}
function handleComplete() {
// Default: stories without explicit decision → OPEN
const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {}
sprintStories.forEach(s => {
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} />
<Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus />
<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>
</div>
</form>
@ -131,51 +149,66 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
{/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Sprint datums instellen</DialogTitle>
</DialogHeader>
<form action={datesFormAction} className="space-y-4 p-1">
<Dialog open={editingDates} onOpenChange={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={datesKeyDown}
className={entityDialogContentClasses}
>
<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} />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
{datesFieldError('start_date') && (
<p className="text-xs text-error">{datesFieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<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" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
{datesFieldError('end_date') && (
<p className="text-xs text-error">{datesFieldError('end_date')}</p>
)}
</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>
<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>
</Dialog>
<DirtyCloseGuardDialog guard={datesCloseGuard} />
{/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Sprint afronden</DialogTitle>
</DialogHeader>
<div className="space-y-4 p-1">
<DialogContent showCloseButton={false} className={entityDialogContentClasses}>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle>
</div>
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
<p className="text-sm text-muted-foreground">
Geef per story aan wat er mee moet gebeuren:
</p>
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="space-y-2">
{sprintStories.map(story => (
<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>}
@ -197,11 +230,17 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
</div>
))}
</div>
</div>
<div className={entityDialogFooterClasses}>
<div className="flex gap-2 justify-end">
<Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button>
<Button disabled={isCompleting} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
<Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button disabled={isCompleting || isDemo} onClick={handleComplete}>
{isCompleting ? 'Bezig…' : 'Sprint afronden'}
</Button>
</DemoTooltip>
</div>
</div>
</DialogContent>

View file

@ -1,62 +1,92 @@
'use client'
import { useState, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useState, useActionState, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} 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'
interface StartSprintButtonProps {
productId: string
isDemo?: boolean
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
)
interface ActionResult {
success?: boolean
error?: string
code?: number
fieldErrors?: Record<string, string[]>
sprintId?: string
}
export function StartSprintButton({ productId }: StartSprintButtonProps) {
export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) {
const [open, setOpen] = useState(false)
const [dirty, setDirty] = useState(false)
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter()
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createSprintAction(_prev, fd)
if (result.success) {
const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>(
async (_prev, fd) => {
const result = await createSprintAction(_prev, fd) as ActionResult
if (result?.success) {
setOpen(false)
setDirty(false)
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
},
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 (
<>
<Button size="sm" onClick={() => setOpen(true)}>
Sprint starten
</Button>
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={() => setOpen(true)} disabled={isDemo}>
Sprint starten
</Button>
</DemoTooltip>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Nieuwe Sprint starten</DialogTitle>
</DialogHeader>
<Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
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} />
<div className="space-y-1.5">
@ -69,9 +99,11 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
aria-invalid={!!fieldError('sprint_goal')}
className={fieldError('sprint_goal') ? 'border-error' : ''}
/>
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).sprint_goal && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).sprint_goal[0]}</p>
{fieldError('sprint_goal') && (
<p className="text-xs text-error">{fieldError('sprint_goal')}</p>
)}
</div>
@ -79,15 +111,15 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
<div className="space-y-1.5">
<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" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
{fieldError('start_date') && (
<p className="text-xs text-error">{fieldError('start_date')}</p>
)}
</div>
<div className="space-y-1.5">
<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" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
{fieldError('end_date') && (
<p className="text-xs text-error">{fieldError('end_date')}</p>
)}
</div>
</div>
@ -97,16 +129,22 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
{globalError}
</div>
)}
</form>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
Annuleren
</Button>
<SubmitButton />
<Button type="submit" form="start-sprint-form" disabled={pending}>
{pending ? 'Aanmaken…' : 'Sprint starten'}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}