- TriplePane component with two resizable dividers, localStorage persistence, mobile tabs - SprintBoardClient replaces SprintBacklogClient + PlanningRightClient - Left panel: Product Backlog (PBIs with stories to add to sprint) - Middle panel: Sprint Backlog (stories in sprint, click to select, sortable) - Right panel: TaskList for selected story - /sprint/planning redirects to /sprint - Remove PlanningLeft, PlanningRightClient, SprintBacklogClient Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
257 lines
9.9 KiB
TypeScript
257 lines
9.9 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useTransition, useEffect, useActionState } from 'react'
|
||
import { useFormStatus } from 'react-dom'
|
||
import {
|
||
DndContext, DragEndEvent, DragOverlay,
|
||
KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter,
|
||
} from '@dnd-kit/core'
|
||
import {
|
||
SortableContext, useSortable, verticalListSortingStrategy, arrayMove,
|
||
sortableKeyboardCoordinates,
|
||
} 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} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
|
||
<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} aria-label="Verwijder taak" 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 [state, formAction] = useActionState(
|
||
async (_prev: unknown, fd: FormData) => {
|
||
const result = await createTaskAction(_prev, fd)
|
||
if (result?.success) { onDone(); return result }
|
||
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Aanmaken mislukt')
|
||
return result
|
||
},
|
||
undefined
|
||
)
|
||
return (
|
||
<form action={formAction} className="flex flex-col gap-1.5 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" />
|
||
<div className="flex gap-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>
|
||
</div>
|
||
{state && 'error' in state && typeof state.error === 'string' && (
|
||
<p className="text-xs text-destructive">{state.error}</p>
|
||
)}
|
||
</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: _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 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||
)
|
||
|
||
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 () => {
|
||
const result = await deleteTaskAction(id)
|
||
if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt')
|
||
})
|
||
}
|
||
|
||
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
|
||
id="task-list"
|
||
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>
|
||
)
|
||
}
|