'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 { taskStatusToApi } from '@/lib/task-status' import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { SplitPane } from '@/components/split-pane/split-pane' 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 description: string | null implementation_plan: string | null priority: number sort_order: number status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' verify_only: boolean story_id: string story_code: string | null story_title: string task_code: string | null } export interface SoloBoardProps { productId: string sprintGoal: string tasks: SoloTask[] unassignedStories: UnassignedStory[] isDemo: boolean currentUserId: string repoUrl?: string | null } 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, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, }: SoloBoardProps) { const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() const [batchPending, startBatchTransition] = 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 taskId = active.id as string const task = tasks[taskId] if (!task) return if (getColumnStatus(task.status) === toStatus) return const prevStatus = optimisticMove(taskId, toStatus) if (!prevStatus) return // Onderdruk realtime-echo van onze eigen write — de Postgres-trigger // vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker // zou de store nogmaals een set() doen of de optimistic state // overschrijven. clearPending na de fetch (succes of fail). // // We gebruiken bewust een fetch-based Route Handler in plaats van // de updateTaskStatusAction Server Action — Server Actions // triggeren een full route-tree refresh die de open SSE-stream van // /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten // en realtime-events missen. markPending(taskId) startTransition(async () => { try { const res = await fetch(`/api/tasks/${taskId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ status: taskStatusToApi(toStatus) }), }) if (!res.ok) { rollback(taskId, prevStatus) toast.error('Status bijwerken mislukt — taak teruggeplaatst') } } catch { rollback(taskId, prevStatus) toast.error('Status bijwerken mislukt — taak teruggeplaatst') } finally { clearPending(taskId) } }) } const activeTask = activeDragId ? tasks[activeDragId] : null const queueableCount = columnTasks.TO_DO.filter(t => { const job = claudeJobsByTaskId[t.id] return !job || (job.status !== 'queued' && job.status !== 'claimed' && job.status !== 'running') }).length function handleStartAll() { if (queueableCount === 0) return startBatchTransition(async () => { const result = await enqueueAllTodoJobsAction(productId) if ('error' in result) { toast.error(result.error) } else if (result.count === 0) { toast.info('Geen taken om te starten') } else { toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`) } }) } return (
{sprintGoal && (

{sprintGoal}

)}
setSelectedTask(t)} />, setSelectedTask(t)} />, setSelectedTask(t)} />, ]} />
{activeTask && }
setSelectedTask(null)} /> setUnassignedStories(prev => prev.filter(s => s.id !== id))} />
) }