From 1341163e3450b5976fab1e2cfb3afa647cacdf1a Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 26 Apr 2026 16:55:45 +0200 Subject: [PATCH] feat(ST-357): add task detail dialog and updateTaskPlanAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - updateTaskPlanAction: requireProductWriter, Zod validation, tenant-guard, revalidatePath - TaskDetailContent component keyed by task.id avoids setState-in-effect pattern - Save-on-blur: "Bezig met opslaan…" → "Opgeslagen" (fades after 2s) - DemoTooltip + readOnly for demo users; error toast on failure - Footer link "Open in Sprint Board ↗"; updates Zustand store on save Co-Authored-By: Claude Sonnet 4.6 --- actions/tasks.ts | 34 ++++++ components/solo/task-detail-dialog.tsx | 145 +++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 components/solo/task-detail-dialog.tsx diff --git a/actions/tasks.ts b/actions/tasks.ts index 1cce66e..4dcbc69 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -7,6 +7,7 @@ import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' +import { requireProductWriter } from '@/lib/auth' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -101,6 +102,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P await prisma.task.update({ where: { id }, data: { status } }) revalidatePath(`/products/${task.story.product_id}/sprint/planning`) + revalidatePath(`/products/${task.story.product_id}/solo`) return { success: true } } @@ -121,6 +123,38 @@ export async function deleteTaskAction(id: string) { return { success: true } } +const updateTaskPlanSchema = z.object({ + taskId: z.string().min(1), + productId: z.string().min(1), + implementationPlan: z.string().max(10000), +}) + +export async function updateTaskPlanAction(taskId: string, productId: string, implementationPlan: string) { + try { + await requireProductWriter(productId) + } catch (e) { + return { error: e instanceof Error ? e.message : 'Niet geautoriseerd' } + } + + const parsed = updateTaskPlanSchema.safeParse({ taskId, productId, implementationPlan }) + if (!parsed.success) return { error: 'Ongeldige invoer' } + + const task = await prisma.task.findFirst({ + where: { id: taskId, story: { product_id: productId } }, + include: { story: true }, + }) + if (!task) return { error: 'Taak niet gevonden' } + + await prisma.task.update({ + where: { id: taskId }, + data: { implementation_plan: implementationPlan || null }, + }) + + revalidatePath(`/products/${productId}/solo`) + revalidatePath(`/products/${productId}/sprint/planning`) + return { success: true } +} + export async function reorderTasksAction(storyId: string, orderedIds: string[]) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx new file mode 100644 index 0000000..a325039 --- /dev/null +++ b/components/solo/task-detail-dialog.tsx @@ -0,0 +1,145 @@ +'use client' + +import { useRef, useState, useTransition } from 'react' +import Link from 'next/link' +import { toast } from 'sonner' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Badge } from '@/components/ui/badge' +import { Textarea } from '@/components/ui/textarea' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { useSoloStore } from '@/stores/solo-store' +import { updateTaskPlanAction } from '@/actions/tasks' +import { cn } from '@/lib/utils' +import type { SoloTask } from './solo-board' + +const STATUS_COLORS: Record = { + TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30', + IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + REVIEW: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', + DONE: 'bg-status-done/15 text-status-done border-status-done/30', +} + +const STATUS_LABELS: Record = { + TO_DO: 'To Do', + IN_PROGRESS: 'Bezig', + REVIEW: 'Review', + DONE: 'Klaar', +} + +interface TaskDetailDialogProps { + task: SoloTask | null + productId: string + isDemo: boolean + onClose: () => void +} + +interface TaskDetailContentProps { + task: SoloTask + productId: string + isDemo: boolean + onClose: () => void +} + +type SaveState = 'idle' | 'saving' | 'saved' + +function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) { + const { updatePlan } = useSoloStore() + const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '') + const [saveState, setSaveState] = useState('idle') + const [, startTransition] = useTransition() + const fadeTimer = useRef | null>(null) + const savedPlanRef = useRef(task.implementation_plan ?? '') + + function handleBlur() { + if (isDemo || localPlan === savedPlanRef.current) return + + setSaveState('saving') + if (fadeTimer.current) clearTimeout(fadeTimer.current) + + startTransition(async () => { + const result = await updateTaskPlanAction(task.id, productId, localPlan) + if (result && 'error' in result) { + setSaveState('idle') + toast.error(typeof result.error === 'string' ? result.error : 'Implementatieplan opslaan mislukt') + } else { + savedPlanRef.current = localPlan + updatePlan(task.id, localPlan || null) + setSaveState('saved') + fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000) + } + }) + } + + return ( + <> + +
+ + {task.title} + + + {STATUS_LABELS[task.status]} + +
+

{task.story_title}

+
+ + {task.description && ( +
+

Beschrijving

+

{task.description}

+
+ )} + +
+

Implementatieplan

+ +