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.
280 lines
11 KiB
TypeScript
280 lines
11 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 { 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>
|
||
)
|
||
}
|