'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 { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' 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 verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY' 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 [confirmPending, startConfirmTransition] = useTransition() type BlockerDialogState = { prefixCount: number blockerReason: 'task-review' | 'pbi-blocked' blockerLabel: string prefixIds: string[] } const [blockerDialog, setBlockerDialog] = useState(null) 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 preview = await previewEnqueueAllAction(productId) if ('error' in preview) { toast.error(preview.error) return } if (preview.blockerIndex === null) { const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id) const result = await enqueueClaudeJobsBatchAction(productId, todoIds) 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`) } } else { const blockerTask = preview.tasks[preview.blockerIndex] const blockerLabel = preview.blockerReason === 'task-review' ? `${blockerTask.story_title} — ${blockerTask.title}` : blockerTask.story_title setBlockerDialog({ prefixCount: preview.blockerIndex, blockerReason: preview.blockerReason!, blockerLabel, prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id), }) } }) } function handleBlockerConfirm() { if (!blockerDialog) return const { prefixIds } = blockerDialog setBlockerDialog(null) startConfirmTransition(async () => { const result = await enqueueClaudeJobsBatchAction(productId, prefixIds) 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))} /> {blockerDialog && ( { if (!v) setBlockerDialog(null) }} prefixCount={blockerDialog.prefixCount} blockerReason={blockerDialog.blockerReason} blockerLabel={blockerDialog.blockerLabel} onConfirm={handleBlockerConfirm} onCancel={() => setBlockerDialog(null)} /> )}
) }