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:
parent
90598e9378
commit
e1903bc16c
4 changed files with 291 additions and 5 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
64
components/solo/solo-column.tsx
Normal file
64
components/solo/solo-column.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
components/solo/solo-task-card.tsx
Normal file
60
components/solo/solo-task-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue