diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 3ed956a..149d7d5 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -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(null) + const [selectedTask, setSelectedTask] = useState(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 = { + 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 = ( +
+ {COLUMN_STATUSES.map(status => ( + setSelectedTask(t)} + /> + ))} +
+ ) + return ( -
-

Solo bord wordt geladen in ST-356…

+
+
+
+

{productName}

+ {sprintGoal && ( +

{sprintGoal}

+ )} +
+ +
+ + + {columns} + + {activeTask && } + + + + setSelectedTask(null)} + /> + + setUnassignedStories(prev => prev.filter(s => s.id !== id))} + />
) } diff --git a/components/solo/solo-column.tsx b/components/solo/solo-column.tsx new file mode 100644 index 0000000..04b0cd8 --- /dev/null +++ b/components/solo/solo-column.tsx @@ -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 ( +
+
+ {config.label} + {tasks.length} +
+ +
+ {tasks.map(task => ( + onTaskClick(task)} + /> + ))} + {tasks.length === 0 && ( +

Geen taken

+ )} +
+
+ ) +} diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx new file mode 100644 index 0000000..22ff289 --- /dev/null +++ b/components/solo/solo-task-card.tsx @@ -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 = { + 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 ( +
+

{task.title}

+

{task.story_title}

+
+ ) +} + +export function SoloTaskCardOverlay({ task }: { task: SoloTask }) { + return ( +
+

{task.title}

+

{task.story_title}

+
+ ) +} diff --git a/stores/solo-store.ts b/stores/solo-store.ts new file mode 100644 index 0000000..fa39f0e --- /dev/null +++ b/stores/solo-store.ts @@ -0,0 +1,32 @@ +import { create } from 'zustand' +import type { SoloTask } from '@/components/solo/solo-board' + +type TaskStatus = SoloTask['status'] + +interface SoloStore { + tasks: Record + 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((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 } } })), +}))