diff --git a/app/_components/tasks/priority-segmented.tsx b/app/_components/tasks/priority-segmented.tsx
new file mode 100644
index 0000000..4888e59
--- /dev/null
+++ b/app/_components/tasks/priority-segmented.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { cn } from '@/lib/utils'
+
+const PRIORITIES = [
+ {
+ value: 1,
+ label: 'P1 Critical',
+ selected: 'bg-error-container text-on-error-container border-transparent',
+ },
+ {
+ value: 2,
+ label: 'P2 High',
+ selected: 'bg-priority-high/15 text-priority-high border-priority-high/30',
+ },
+ {
+ value: 3,
+ label: 'P3 Medium',
+ selected: 'bg-primary text-primary-foreground border-transparent',
+ },
+ {
+ value: 4,
+ label: 'P4 Low',
+ selected: 'bg-muted text-foreground border-border',
+ },
+]
+
+interface PrioritySegmentedProps {
+ value: number
+ onChange: (value: number) => void
+ disabled?: boolean
+}
+
+export function PrioritySegmented({ value, onChange, disabled }: PrioritySegmentedProps) {
+ return (
+
+ {PRIORITIES.map(p => (
+
+ ))}
+
+ )
+}
diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx
new file mode 100644
index 0000000..5ba794d
--- /dev/null
+++ b/app/_components/tasks/status-select.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+import type { TaskStatus } from '@prisma/client'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+} from '@/components/ui/select'
+import { cn } from '@/lib/utils'
+
+const STATUS_CONFIG: Record = {
+ TO_DO: { label: 'To Do', dot: 'bg-muted-foreground' },
+ IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' },
+ REVIEW: { label: 'Review', dot: 'bg-status-review' },
+ DONE: { label: 'Klaar', dot: 'bg-status-done' },
+}
+
+const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
+
+function StatusIndicator({ status }: { status: TaskStatus }) {
+ return (
+
+
+ {STATUS_CONFIG[status].label}
+
+ )
+}
+
+interface StatusSelectProps {
+ value?: TaskStatus
+ onChange: (value: TaskStatus) => void
+ disabled?: boolean
+}
+
+export function StatusSelect({ value = 'TO_DO', onChange, disabled }: StatusSelectProps) {
+ return (
+
+ )
+}
diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx
new file mode 100644
index 0000000..2426dc1
--- /dev/null
+++ b/app/_components/tasks/task-dialog.tsx
@@ -0,0 +1,424 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { useForm, Controller } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import TextareaAutosize from 'react-textarea-autosize'
+import { toast } from 'sonner'
+import { Loader2 } from 'lucide-react'
+import type { TaskStatus } from '@prisma/client'
+import { taskSchema, type TaskInput } from '@/lib/schemas/task'
+import { saveTask, deleteTask } from '@/actions/tasks'
+import {
+ Dialog,
+ DialogContent,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogAction,
+ AlertDialogCancel,
+} from '@/components/ui/alert-dialog'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { DemoTooltip } from '@/components/shared/demo-tooltip'
+import { PrioritySegmented } from './priority-segmented'
+import { StatusSelect } from './status-select'
+import { cn } from '@/lib/utils'
+
+export interface TaskDialogTask {
+ id: string
+ title: string
+ description: string | null
+ implementation_plan: string | null
+ priority: number
+ status: TaskStatus
+ created_at: Date
+}
+
+interface TaskDialogProps {
+ task?: TaskDialogTask
+ storyId?: string
+ productId: string
+ closePath: string
+ isDemo?: boolean
+}
+
+function CharCount({ value, max }: { value: string; max: number }) {
+ const len = (value ?? '').length
+ if (len < Math.floor(max * 0.75)) return null
+ return (
+
+ {len} / {max}
+
+ )
+}
+
+const textareaClass = cn(
+ 'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm',
+ 'transition-colors outline-none placeholder:text-muted-foreground resize-none',
+ 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50',
+ 'overflow-y-auto',
+)
+
+export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) {
+ const router = useRouter()
+ const [isPending, startTransition] = useTransition()
+ const [confirmClose, setConfirmClose] = useState(false)
+ const [confirmDelete, setConfirmDelete] = useState(false)
+ const isEdit = !!task
+
+ const form = useForm({
+ resolver: zodResolver(taskSchema),
+ mode: 'onTouched',
+ defaultValues: {
+ title: task?.title ?? '',
+ description: task?.description ?? '',
+ implementation_plan: task?.implementation_plan ?? '',
+ priority: task?.priority ?? 3,
+ status: task?.status,
+ },
+ })
+
+ function handleClose() {
+ router.push(closePath)
+ }
+
+ function handleAttemptClose() {
+ if (form.formState.isDirty) {
+ 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) {
+ startTransition(async () => {
+ const result = await saveTask(data, {
+ taskId: task?.id,
+ storyId,
+ productId,
+ })
+
+ if (result.ok) {
+ toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt')
+ router.push(closePath)
+ return
+ }
+
+ if (result.code === 422 && result.error === 'validation') {
+ for (const [field, errors] of Object.entries(result.fieldErrors)) {
+ form.setError(field as keyof TaskInput, { message: errors[0] })
+ }
+ const firstError = Object.keys(result.fieldErrors)[0] as keyof TaskInput
+ form.setFocus(firstError)
+ return
+ }
+
+ if (result.code === 403) {
+ toast.error(
+ result.error === 'demo_readonly'
+ ? 'Demo-modus: opslaan uitgeschakeld'
+ : 'Geen toegang',
+ )
+ return
+ }
+
+ toast.error('Er ging iets mis. Probeer het opnieuw.', {
+ action: { label: 'Opnieuw', onClick: () => form.handleSubmit(onSubmit)() },
+ })
+ })
+ }
+
+ function handleDelete() {
+ if (!task) return
+ setConfirmDelete(false)
+ startTransition(async () => {
+ const result = await deleteTask(task.id, { productId })
+ if (result.ok) {
+ toast.success('Taak verwijderd')
+ router.push(closePath)
+ return
+ }
+ if (result.code === 403) {
+ toast.error(
+ result.error === 'demo_readonly'
+ ? 'Demo-modus: verwijderen uitgeschakeld'
+ : 'Geen toegang',
+ )
+ return
+ }
+ toast.error('Verwijderen mislukt')
+ })
+ }
+
+ return (
+ <>
+
+
+ {/* Dirty-check confirm */}
+
+
+
+ Wijzigingen niet opgeslagen
+
+ Wil je de wijzigingen weggooien?
+
+
+
+ setConfirmClose(false)}>
+ Terug
+
+ { setConfirmClose(false); handleClose() }}
+ >
+ Weggooien
+
+
+
+
+
+ {/* Delete confirm */}
+
+
+
+ Taak verwijderen
+
+ Weet je zeker dat je deze taak wilt verwijderen? Dit kan niet ongedaan worden gemaakt.
+
+
+
+ setConfirmDelete(false)}>
+ Annuleren
+
+
+ {isPending ? : 'Verwijderen'}
+
+
+
+
+ >
+ )
+}