feat(ST-357): add task detail dialog and updateTaskPlanAction

- 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 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 16:55:45 +02:00
parent e1903bc16c
commit 1341163e34
2 changed files with 179 additions and 0 deletions

View file

@ -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<string, string> = {
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<string, string> = {
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<SaveState>('idle')
const [, startTransition] = useTransition()
const fadeTimer = useRef<ReturnType<typeof setTimeout> | 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 (
<>
<DialogHeader>
<div className="flex items-start gap-3 pr-8">
<DialogTitle className="text-sm font-medium leading-snug flex-1">
{task.title}
</DialogTitle>
<Badge className={cn('text-xs border shrink-0', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</div>
<p className="text-xs text-muted-foreground">{task.story_title}</p>
</DialogHeader>
{task.description && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p>
<p className="text-sm text-foreground whitespace-pre-wrap">{task.description}</p>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5">Implementatieplan</p>
<DemoTooltip show={isDemo}>
<Textarea
value={localPlan}
onChange={(e) => setLocalPlan(e.target.value)}
onBlur={handleBlur}
placeholder="Voeg een implementatieplan toe…"
className="resize-none text-sm min-h-[120px]"
readOnly={isDemo}
/>
</DemoTooltip>
<div className="flex justify-end mt-1 h-4">
{saveState === 'saving' && (
<span className="text-xs text-muted-foreground">Bezig met opslaan</span>
)}
{saveState === 'saved' && (
<span className="text-xs text-status-done">Opgeslagen</span>
)}
</div>
</div>
<div className="-mx-4 -mb-4 flex items-center border-t bg-muted/50 px-4 py-3 rounded-b-xl">
<Link
href={`/products/${productId}/sprint/planning`}
className="text-xs text-primary hover:underline"
onClick={onClose}
>
Open in Sprint Board
</Link>
</div>
</>
)
}
export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetailDialogProps) {
return (
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent className="sm:max-w-lg">
{task && (
<TaskDetailContent
key={task.id}
task={task}
productId={productId}
isDemo={isDemo}
onClose={onClose}
/>
)}
</DialogContent>
</Dialog>
)
}