'use client' import { useEffect, useState, useTransition } from 'react' import { useShallow } from 'zustand/react/shallow' import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, closestCorners, } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' import { selectSoloTaskById, selectSoloTasksForColumn, selectSoloUnassignedStories, } from '@/stores/solo-workspace/selectors' import type { SoloTask, SoloUnassignedStory, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' import { taskStatusToApi } from '@/lib/task-status' import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' import { debugProps } from '@/lib/debug' 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 } from './unassigned-stories-sheet' export type { SoloTask } from '@/stores/solo-workspace/types' export interface SoloBoardProps { productId: string sprintGoal: string tasks?: SoloTask[] unassignedStories?: SoloUnassignedStory[] 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, currentUserId, }: SoloBoardProps) { const { tasks, hydrateSnapshot, optimisticMove, rollback, markPending, clearPending, removeUnassignedStory, } = useSoloStore() const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState(null) const [selectedTaskId, setSelectedTaskId] = useState(null) const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId)) const [sheetOpen, setSheetOpen] = useState(false) 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) useEffect(() => { if (!initialTasks || !initialUnassigned || !currentUserId) return const snapshot: SoloWorkspaceSnapshot = { product: { id: productId, name: '' }, sprint: { id: `compat:${productId}`, sprint_goal: sprintGoal }, activeUserId: currentUserId, tasks: initialTasks, unassignedStories: initialUnassigned, } hydrateSnapshot(snapshot) }, [currentUserId, hydrateSnapshot, initialTasks, initialUnassigned, productId, sprintGoal]) const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) const sensors = useSensors(...(isDemo ? [] : [pointerSensor])) const columnTasks: Record = { TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))), IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))), DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))), } const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories)) 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}

)}
setSelectedTaskId(t.id)} />, setSelectedTaskId(t.id)} />, setSelectedTaskId(t.id)} />, ]} />
{activeTask && }
setSelectedTaskId(null)} /> {blockerDialog && ( { if (!v) setBlockerDialog(null) }} prefixCount={blockerDialog.prefixCount} blockerReason={blockerDialog.blockerReason} blockerLabel={blockerDialog.blockerLabel} onConfirm={handleBlockerConfirm} onCancel={() => setBlockerDialog(null)} /> )}
) }