Scrum4Me/components/sprint/task-list.tsx
Madhura68 5e0308d42e feat(ST-1110.5): unify demo write-button pattern to disabled+tooltip
Convert all !isDemo && <Button> patterns to <DemoTooltip show={isDemo}>
<Button disabled={isDemo}> so demo visitors see app capabilities.
Affects: pbi-list, story-panel, story-dialog, task-list, sprint-backlog,
token-manager, product-list, activate-product-button, leave-product-button,
settings page.
2026-04-29 18:27:39 +02:00

280 lines
11 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 { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { deriveTaskCode } from '@/lib/code'
import { useSprintStore } from '@/stores/sprint-store'
import {
createTaskAction, updateTaskStatusAction, updateTaskAction,
deleteTaskAction, reorderTasksAction,
} from '@/actions/tasks'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
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' }
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-2 py-1">
<div className={cn('rounded border border-border px-3 py-2 bg-surface-container', PRIORITY_BORDER[task.priority])}>
<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>
</div>
)
}
return (
<div ref={setNodeRef} style={style} className="group px-2 py-1">
<div className={cn(
'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high',
PRIORITY_BORDER[task.priority]
)}>
{!isDemo && (
<span {...attributes} {...listeners} className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" aria-hidden="true"></span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn('text-sm leading-snug line-clamp-2 flex-1', task.status === 'DONE' && 'line-through text-muted-foreground')}>
{task.title}
</p>
{code && <CodeBadge code={code} className="shrink-0 mt-0.5" />}
</div>
<div className="flex items-center justify-between gap-2 mt-1.5">
<button onClick={onStatusToggle} disabled={isDemo} aria-label={`Status: ${STATUS_LABELS[task.status]}`}>
<Badge className={cn('text-[10px] px-1.5 py-0 border cursor-pointer hover:opacity-80 transition-opacity', STATUS_COLORS[task.status])}>
{STATUS_LABELS[task.status]}
</Badge>
</button>
<div className="opacity-0 group-hover:opacity-100 flex gap-1 shrink-0">
<DemoTooltip show={isDemo}>
<button onClick={() => !isDemo && setEditing(true)} disabled={isDemo} className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed">Bewerk</button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<button onClick={() => !isDemo && onDelete()} disabled={isDemo} aria-label="Verwijder taak" className="text-xs text-muted-foreground hover:text-error disabled:opacity-40 disabled:cursor-not-allowed">×</button>
</DemoTooltip>
</div>
</div>
</div>
</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>
<DemoTooltip show={isDemo}>
<Button size="sm" className="h-7 text-xs" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>+ Taak</Button>
</DemoTooltip>
</>
}
/>
<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>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} onClick={() => !isDemo && setCreating(true)}>Maak eerste taak aan</Button>
</DemoTooltip>
</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={cn(
'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm',
PRIORITY_BORDER[taskMap[activeDragId].priority]
)}>
{taskMap[activeDragId].title}
</div>
)}
</DragOverlay>
</DndContext>
)}
</div>
</div>
)
}