Scrum4Me/components/sprint/task-list.tsx
janpeter visser d92e548f88 feat: ST-301-ST-312 M3 Sprint Backlog en Sprint Planning
- useSprintStore met sprintStoryOrder/taskOrder (ST-301)
- Sprint aanmaken modal met Sprint Goal validatie (ST-302)
- Sprint Backlog pagina SplitPane layout met Sprint Goal header (ST-303)
- Stories toevoegen aan Sprint via knop in rechterpaneel (ST-304)
- Sprint Backlog volgorde aanpassen via dnd-kit (ST-305)
- Story uit Sprint verwijderen met status terug naar OPEN (ST-306)
- Sprint Planning pagina SplitPane met story selectie (ST-307)
- Taken aanmaken inline in rechterpaneel (ST-308)
- Taak drag-and-drop verticaal met optimistische update (ST-309)
- Taakstatus toggle TO_DO/IN_PROGRESS/DONE met voortgangsindicator (ST-310)
- Taak inline bewerken en verwijderen (ST-311)
- Sprint afronden dialoog met per-story Done/Terug keuze (ST-312)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 11:51:27 +02:00

245 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition, useEffect, useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core'
import {
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSprintStore } from '@/stores/sprint-store'
import {
createTaskAction, updateTaskStatusAction, updateTaskAction,
deleteTaskAction, reorderTasksAction,
} from '@/actions/tasks'
import { cn } from '@/lib/utils'
const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = {
TO_DO: 'IN_PROGRESS',
IN_PROGRESS: 'DONE',
DONE: 'TO_DO',
}
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',
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', DONE: 'Klaar' }
const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', 2: 'Hoog', 3: 'Gemiddeld', 4: 'Laag' }
export interface Task {
id: string
title: string
description: string | null
priority: number
status: string
story_id: string
sprint_id: string | null
}
interface TaskListProps {
storyId: string
sprintId: string
productId: string
tasks: Task[]
isDemo: boolean
}
function SortableTaskRow({
task, isDemo, onStatusToggle, onDelete,
}: { task: Task; isDemo: boolean; onStatusToggle: () => void; onDelete: () => void }) {
const [editing, setEditing] = useState(false)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id })
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 }
const [, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await updateTaskAction(_prev, fd)
if (result?.success) setEditing(false)
return result
},
undefined
)
if (editing) {
return (
<div ref={setNodeRef} style={style} className="px-4 py-2 border-b border-border bg-surface-container">
<form action={formAction} className="space-y-2">
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="priority" value={task.priority} />
<Input name="title" defaultValue={task.title} className="h-7 text-sm" required autoFocus />
<div className="flex gap-2">
<EditSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={() => setEditing(false)}>Annuleren</Button>
</div>
</form>
</div>
)
}
return (
<div ref={setNodeRef} style={style} className="group flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-surface-container/50 transition-colors">
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none"></span>
)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm truncate', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
<span className="text-xs text-muted-foreground">{PRIORITY_LABELS[task.priority]}</span>
</div>
<button onClick={onStatusToggle} disabled={isDemo}>
<Badge className={cn('text-xs border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</button>
{!isDemo && (
<div className="opacity-0 group-hover:opacity-100 flex gap-1">
<button onClick={() => setEditing(true)} className="text-xs text-muted-foreground hover:text-foreground">Bewerk</button>
<button onClick={onDelete} className="text-xs text-muted-foreground hover:text-error">×</button>
</div>
)}
</div>
)
}
function EditSubmitButton() {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Opslaan'}</Button>
}
function CreateTaskForm({ storyId, sprintId, onDone }: { storyId: string; sprintId: string; onDone: () => void }) {
const [, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await createTaskAction(_prev, fd)
if (result?.success) onDone()
return result
},
undefined
)
return (
<form action={formAction} className="flex gap-2 px-4 py-2 border-b border-border">
<input type="hidden" name="storyId" value={storyId} />
<input type="hidden" name="sprintId" value={sprintId} />
<input type="hidden" name="priority" value="2" />
<Input name="title" autoFocus placeholder="Taaknaam…" className="h-7 text-sm flex-1" required />
<CreateSubmitButton />
<Button type="button" variant="ghost" size="sm" className="h-7" onClick={onDone}>×</Button>
</form>
)
}
function CreateSubmitButton() {
const { pending } = useFormStatus()
return <Button type="submit" size="sm" className="h-7" disabled={pending}>{pending ? '…' : 'Toevoegen'}</Button>
}
export function TaskList({ storyId, sprintId, productId, tasks, isDemo }: TaskListProps) {
const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore()
const [creating, setCreating] = useState(false)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition()
const idKey = tasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(storyId, idKey ? idKey.split(',') : [])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storyId, idKey])
const taskMap = Object.fromEntries(tasks.map(t => [t.id, t]))
const order = taskOrder[storyId] ?? tasks.map(t => t.id)
const orderedTasks = order.map(id => taskMap[id]).filter(Boolean)
const doneCount = orderedTasks.filter(t => t.status === 'DONE').length
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
const prevOrder = [...order]
const newOrder = arrayMove([...order], order.indexOf(active.id as string), order.indexOf(over.id as string))
reorderTasks(storyId, newOrder)
setActiveDragId(null)
startTransition(async () => {
const result = await reorderTasksAction(storyId, newOrder)
if (!result.success) { rollbackTasks(storyId, prevOrder); toast.error('Volgorde opslaan mislukt') }
})
}
function handleStatusToggle(task: Task) {
startTransition(async () => {
await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO')
})
}
function handleDelete(id: string) {
startTransition(async () => {
await deleteTaskAction(id)
})
}
return (
<div className="flex flex-col h-full">
<PanelNavBar
title="Taken"
actions={
<>
<span className="text-xs text-muted-foreground">{doneCount}/{orderedTasks.length} klaar</span>
{!isDemo && (
<Button size="sm" className="h-7 text-xs" onClick={() => setCreating(true)}>+ Taak</Button>
)}
</>
}
/>
<div className="flex-1 overflow-y-auto">
{creating && (
<CreateTaskForm storyId={storyId} sprintId={sprintId} onDone={() => setCreating(false)} />
)}
{orderedTasks.length === 0 && !creating ? (
<div className="text-center mt-8 space-y-3">
<p className="text-sm text-muted-foreground">Geen taken voor deze story.</p>
{!isDemo && <Button size="sm" variant="outline" onClick={() => setCreating(true)}>Maak eerste taak aan</Button>}
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={e => setActiveDragId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
{orderedTasks.map(task => (
<SortableTaskRow
key={task.id}
task={task}
isDemo={isDemo}
onStatusToggle={() => handleStatusToggle(task)}
onDelete={() => handleDelete(task.id)}
/>
))}
</SortableContext>
<DragOverlay>
{activeDragId && taskMap[activeDragId] && (
<div className="bg-surface-container-low border border-primary rounded px-4 py-2 text-sm shadow-lg opacity-90">
{taskMap[activeDragId].title}
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</div>
)
}