From 59e214fc12fcae9eede160ad9d8d199e57e8577b Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 29 Apr 2026 23:46:41 +0200 Subject: [PATCH] feat(ST-1112): add TaskDialog component (create & edit mode) Builds the full TaskDialog on top of the existing @base-ui/react Dialog primitive. Covers create mode, edit mode (status field + created_at metadata + delete), dirty-check AlertDialog, delete confirm AlertDialog, Cmd+Enter submit, and per-field char counters. Uses react-hook-form + zodResolver against the shared taskSchema. Priority and status are extracted to PrioritySegmented and StatusSelect sub-components using MD3 tokens throughout. Co-Authored-By: Claude Sonnet 4.6 --- app/_components/tasks/priority-segmented.tsx | 56 +++ app/_components/tasks/status-select.tsx | 55 +++ app/_components/tasks/task-dialog.tsx | 424 +++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 app/_components/tasks/priority-segmented.tsx create mode 100644 app/_components/tasks/status-select.tsx create mode 100644 app/_components/tasks/task-dialog.tsx 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 ( + <> + { if (!open) handleAttemptClose() }}> + + {/* Sticky header */} +
+ + {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} + + {isEdit && ( + + Aangemaakt:{' '} + {new Intl.DateTimeFormat('nl-NL', { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(new Date(task.created_at))} + + )} +
+ + {/* Scrollable form body */} +
+ {/* Title */} +
+ + { if (e.key === 'Enter') e.preventDefault() }} + /> + {form.formState.errors.title && ( +

+ {form.formState.errors.title.message} +

+ )} +
+ + {/* Description */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.description && ( +

+ {form.formState.errors.description.message} +

+ )} +
+ + {/* Implementation plan */} +
+ + ( + <> + + +

+ Markdown ondersteund (lijstjes, **vet**, `code`) +

+ + )} + /> + {form.formState.errors.implementation_plan && ( +

+ {form.formState.errors.implementation_plan.message} +

+ )} +
+ + {/* Priority */} +
+ + ( + + )} + /> +
+ + {/* Status — edit only */} + {isEdit && ( +
+ + ( + + )} + /> +
+ )} +
+ + {/* Sticky footer */} +
+
+ {isEdit ? ( + + + + ) : ( +
+ )} + +
+ + + + +
+
+
+ +
+ + {/* 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'} + + + + + + ) +}