4-regels layout: taaknaam+task_code badge (tooltip: naam+beschrijving), beschrijving+pbi_code badge (tooltip: pbi_title+pbi_description), story+job-badge. SoloTaskCardOverlay identieke 4-regels structuur zonder tooltips. PBI-velden toegevoegd aan SoloTask-interface + Prisma-queries + test-fixtures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
'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
|
|
pbi_code: string | null
|
|
pbi_title: string | null
|
|
pbi_description: 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<string | null>(null)
|
|
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(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<BlockerDialogState | null>(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<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 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 (
|
|
<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 flex items-center gap-3">
|
|
<DemoTooltip show={isDemo}>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleStartAll}
|
|
disabled={isDemo || batchPending || confirmPending || queueableCount === 0}
|
|
>
|
|
{batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`}
|
|
</Button>
|
|
</DemoTooltip>
|
|
{sprintGoal && (
|
|
<p className="text-sm text-muted-foreground line-clamp-2 min-w-0">{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}
|
|
>
|
|
<div className="flex-1 min-h-0">
|
|
<SplitPane
|
|
cookieKey={`solo-${productId}`}
|
|
defaultSplit={[33, 33, 34]}
|
|
tabLabels={['Te doen', 'Bezig', 'Klaar']}
|
|
panes={[
|
|
<SoloColumn
|
|
key="TO_DO"
|
|
status="TO_DO"
|
|
tasks={columnTasks.TO_DO}
|
|
isDemo={isDemo}
|
|
onTaskClick={(t) => setSelectedTask(t)}
|
|
/>,
|
|
<SoloColumn
|
|
key="IN_PROGRESS"
|
|
status="IN_PROGRESS"
|
|
tasks={columnTasks.IN_PROGRESS}
|
|
isDemo={isDemo}
|
|
onTaskClick={(t) => setSelectedTask(t)}
|
|
/>,
|
|
<SoloColumn
|
|
key="DONE"
|
|
status="DONE"
|
|
tasks={columnTasks.DONE}
|
|
isDemo={isDemo}
|
|
onTaskClick={(t) => setSelectedTask(t)}
|
|
/>,
|
|
]}
|
|
/>
|
|
</div>
|
|
<DragOverlay>
|
|
{activeTask && <SoloTaskCardOverlay task={activeTask} />}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
<TaskDetailDialog
|
|
task={selectedTask}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
repoUrl={repoUrl}
|
|
onClose={() => setSelectedTask(null)}
|
|
/>
|
|
|
|
<UnassignedStoriesSheet
|
|
stories={unassignedStories}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
open={sheetOpen}
|
|
onOpenChange={setSheetOpen}
|
|
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
|
/>
|
|
|
|
{blockerDialog && (
|
|
<BatchEnqueueBlockerDialog
|
|
open
|
|
onOpenChange={(v) => { if (!v) setBlockerDialog(null) }}
|
|
prefixCount={blockerDialog.prefixCount}
|
|
blockerReason={blockerDialog.blockerReason}
|
|
blockerLabel={blockerDialog.blockerLabel}
|
|
onConfirm={handleBlockerConfirm}
|
|
onCancel={() => setBlockerDialog(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|