feat(ST-356): add solo Kanban board with DnD and Zustand store

- useSoloStore: initTasks, optimisticMove (returns prev status), rollback, updatePlan
- SoloBoard: DndContext with PointerSensor (distance:5), closestCorners, 3 columns
- SoloColumn: useDroppable per status, MD3 status-color headers, task count, empty state
- SoloTaskCard + SoloTaskCardOverlay: useDraggable (disabled for demo), priority left-border
- onDragEnd: optimisticMove → updateTaskStatusAction → rollback + toast on error
- REVIEW tasks mapped to IN_PROGRESS column

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-26 16:54:52 +02:00
parent 90598e9378
commit e1903bc16c
4 changed files with 291 additions and 5 deletions

View file

@ -1,5 +1,18 @@
'use client'
import { useEffect, useState, useTransition } from 'react'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCorners,
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import { updateTaskStatusAction } from '@/actions/tasks'
import { SoloColumn, type ColumnStatus } from './solo-column'
import { SoloTaskCardOverlay } from './solo-task-card'
import { TaskDetailDialog } from './task-detail-dialog'
import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet'
export interface SoloTask {
id: string
title: string
@ -17,16 +30,133 @@ export interface SoloBoardProps {
productName: string
sprintGoal: string
tasks: SoloTask[]
unassignedCount: number
unassignedStories: UnassignedStory[]
isDemo: boolean
currentUserId: string
}
// Full implementation in ST-356
export function SoloBoard(_props: SoloBoardProps) {
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
function getColumnStatus(status: SoloTask['status']): ColumnStatus {
if (status === 'REVIEW') return 'IN_PROGRESS'
return status
}
export function SoloBoard({
productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback } = useSoloStore()
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(initialTasks)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskKey])
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
const taskList = Object.values(tasks)
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'),
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'),
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'),
}
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over) return
const toStatus = over.id as ColumnStatus
if (!COLUMN_STATUSES.includes(toStatus)) return
const task = tasks[active.id as string]
if (!task) return
if (getColumnStatus(task.status) === toStatus) return
const prevStatus = optimisticMove(active.id as string, toStatus)
if (!prevStatus) return
startTransition(async () => {
const result = await updateTaskStatusAction(active.id as string, toStatus)
if (result && 'error' in result) {
rollback(active.id as string, prevStatus)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
}
})
}
const activeTask = activeDragId ? tasks[activeDragId] : null
const columns = (
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
{COLUMN_STATUSES.map(status => (
<SoloColumn
key={status}
status={status}
tasks={columnTasks[status]}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>
))}
</div>
)
return (
<div className="flex items-center justify-center h-full">
<p className="text-sm text-muted-foreground">Solo bord wordt geladen in ST-356</p>
<div className="flex flex-col h-full p-4 gap-4 min-h-0">
<div className="flex items-start justify-between gap-4 shrink-0">
<div className="min-w-0">
<h1 className="text-base font-semibold text-foreground truncate">{productName}</h1>
{sprintGoal && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>
)}
</div>
<button
className="text-sm text-primary hover:underline whitespace-nowrap shrink-0 disabled:text-muted-foreground disabled:cursor-default"
disabled={unassignedStories.length === 0}
onClick={() => setSheetOpen(true)}
>
Toon openstaande stories ({unassignedStories.length})
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{columns}
<DragOverlay>
{activeTask && <SoloTaskCardOverlay task={activeTask} />}
</DragOverlay>
</DndContext>
<TaskDetailDialog
task={selectedTask}
productId={productId}
isDemo={isDemo}
onClose={() => setSelectedTask(null)}
/>
<UnassignedStoriesSheet
stories={unassignedStories}
productId={productId}
isDemo={isDemo}
open={sheetOpen}
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
/>
</div>
)
}

View file

@ -0,0 +1,64 @@
'use client'
import { useDroppable } from '@dnd-kit/core'
import { cn } from '@/lib/utils'
import { SoloTaskCard } from './solo-task-card'
import type { SoloTask } from './solo-board'
export const COLUMN_CONFIG = {
TO_DO: {
label: 'To Do',
headerClass: 'bg-status-todo/15 text-status-todo border-b border-status-todo/20',
},
IN_PROGRESS: {
label: 'Bezig',
headerClass: 'bg-status-in-progress/15 text-status-in-progress border-b border-status-in-progress/20',
},
DONE: {
label: 'Klaar',
headerClass: 'bg-status-done/15 text-status-done border-b border-status-done/20',
},
} as const
export type ColumnStatus = keyof typeof COLUMN_CONFIG
interface SoloColumnProps {
status: ColumnStatus
tasks: SoloTask[]
isDemo: boolean
onTaskClick: (task: SoloTask) => void
}
export function SoloColumn({ status, tasks, isDemo, onTaskClick }: SoloColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: status })
const config = COLUMN_CONFIG[status]
return (
<div
ref={setNodeRef}
className={cn(
'flex flex-col rounded-lg border border-border overflow-hidden',
isOver && 'ring-2 ring-primary ring-inset',
)}
>
<div className={cn('flex items-center gap-2 px-3 py-2', config.headerClass)}>
<span className="text-sm font-medium">{config.label}</span>
<span className="text-xs opacity-60 ml-auto">{tasks.length}</span>
</div>
<div className="flex-1 flex flex-col gap-2 p-2 overflow-y-auto min-h-[140px]">
{tasks.map(task => (
<SoloTaskCard
key={task.id}
task={task}
isDemo={isDemo}
onClick={() => onTaskClick(task)}
/>
))}
{tasks.length === 0 && (
<p className="text-xs text-muted-foreground text-center py-8">Geen taken</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,60 @@
'use client'
import { useDraggable } from '@dnd-kit/core'
import { CSS } from '@dnd-kit/utilities'
import { cn } from '@/lib/utils'
import type { SoloTask } from './solo-board'
const PRIORITY_BORDER: Record<number, string> = {
1: 'border-l-4 border-l-priority-critical',
2: 'border-l-4 border-l-priority-high',
3: 'border-l-4 border-l-priority-medium',
4: 'border-l-4 border-l-priority-low',
}
interface SoloTaskCardProps {
task: SoloTask
isDemo: boolean
onClick: () => void
}
export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
disabled: isDemo,
})
const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined
return (
<div
ref={setNodeRef}
style={style}
onClick={onClick}
className={cn(
'bg-surface-container rounded border border-border px-3 py-2 select-none transition-colors',
PRIORITY_BORDER[task.priority],
isDragging ? 'opacity-40 z-50 shadow-lg' : 'hover:bg-surface-container-high',
isDemo ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
)}
{...(!isDemo ? { ...attributes, ...listeners } : {})}
>
<p className="text-sm text-foreground leading-snug">{task.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{task.story_title}</p>
</div>
)
}
export function SoloTaskCardOverlay({ task }: { task: SoloTask }) {
return (
<div
className={cn(
'bg-surface-container rounded border border-primary px-3 py-2 shadow-xl opacity-90',
PRIORITY_BORDER[task.priority],
)}
>
<p className="text-sm text-foreground leading-snug">{task.title}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{task.story_title}</p>
</div>
)
}

32
stores/solo-store.ts Normal file
View file

@ -0,0 +1,32 @@
import { create } from 'zustand'
import type { SoloTask } from '@/components/solo/solo-board'
type TaskStatus = SoloTask['status']
interface SoloStore {
tasks: Record<string, SoloTask>
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void
}
export const useSoloStore = create<SoloStore>((set, get) => ({
tasks: {},
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
optimisticMove: (taskId, toStatus) => {
const prev = get().tasks[taskId]?.status ?? null
if (!prev) return null
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } }))
return prev
},
rollback: (taskId, prevStatus) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })),
updatePlan: (taskId, plan) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
}))