Scrum4Me/components/sprint/task-list.tsx
Madhura68 b71eb53fa8 feat(ST-507): show code badges on cards, lists and dialogs across the app
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:36:59 +02:00

264 lines
10 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,
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 { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { deriveTaskCode } from '@/lib/code'
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
storyCode: string | null
sprintId: string
productId: string
tasks: Task[]
isDemo: boolean
}
function SortableTaskRow({
task, code, isDemo, onStatusToggle, onDelete,
}: { task: Task; code: string | null; 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-start 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 mt-0.5"></span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm truncate flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div>
<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]}`} className="mt-0.5">
<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 mt-0.5">
<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" aria-label="Annuleer" 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, storyCode, 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, idx) => (
<SortableTaskRow
key={task.id}
task={task}
code={deriveTaskCode(storyCode, idx + 1)}
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>
)
}