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:
parent
e1903bc16c
commit
1341163e34
2 changed files with 179 additions and 0 deletions
|
|
@ -7,6 +7,7 @@ import { z } from 'zod'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { SessionData, sessionOptions } from '@/lib/session'
|
import { SessionData, sessionOptions } from '@/lib/session'
|
||||||
import { productAccessFilter } from '@/lib/product-access'
|
import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
import { requireProductWriter } from '@/lib/auth'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
return getIronSession<SessionData>(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 } })
|
await prisma.task.update({ where: { id }, data: { status } })
|
||||||
|
|
||||||
revalidatePath(`/products/${task.story.product_id}/sprint/planning`)
|
revalidatePath(`/products/${task.story.product_id}/sprint/planning`)
|
||||||
|
revalidatePath(`/products/${task.story.product_id}/solo`)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,6 +123,38 @@ export async function deleteTaskAction(id: string) {
|
||||||
return { success: true }
|
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[]) {
|
export async function reorderTasksAction(storyId: string, orderedIds: string[]) {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
|
||||||
145
components/solo/task-detail-dialog.tsx
Normal file
145
components/solo/task-detail-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue