diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx index 0a6d34f..3b767fc 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -60,6 +60,7 @@ const baseTask: SoloTask = { sort_order: 1, status: 'TO_DO', verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL', story_id: 'story-1', story_code: 'ST-100', story_title: 'Test Story', diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts index b2f42cf..f61a7f8 100644 --- a/__tests__/stores/solo-store-realtime.test.ts +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -12,6 +12,7 @@ const baseTask = (id: string, overrides: Partial = {}): SoloTask => ({ sort_order: 1, status: 'TO_DO', verify_only: false, + verify_required: 'ALIGNED_OR_PARTIAL', story_id: 'story-1', story_code: 'ST-100', story_title: 'Original Story', diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 3c9cf86..3ad8a25 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -83,6 +83,7 @@ export default async function SoloProductPage({ params }: Props) { sort_order: t.sort_order, status: t.status as SoloTask['status'], verify_only: t.verify_only, + verify_required: t.verify_required as SoloTask['verify_required'], story_id: t.story.id, story_code: t.story.code, story_title: t.story.title, diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index dd38055..b80a811 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -10,18 +10,22 @@ import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // drive tasks into a state the shared UI can't display. const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review') +const VERIFY_REQUIRED_VALUES = ['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const + const patchSchema = z .object({ status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(), implementation_plan: z.string().optional(), verify_only: z.boolean().optional(), + verify_required: z.enum(VERIFY_REQUIRED_VALUES).optional(), }) .refine( (data) => data.status !== undefined || data.implementation_plan !== undefined || - data.verify_only !== undefined, - { message: 'Geef minimaal status, implementation_plan of verify_only mee' }, + data.verify_only !== undefined || + data.verify_required !== undefined, + { message: 'Geef minimaal status, implementation_plan, verify_only of verify_required mee' }, ) export async function PATCH( @@ -88,19 +92,21 @@ export async function PATCH( } } - // Combine simple field writes (plan, verify_only) into one update call - const simpleData: { implementation_plan?: string; verify_only?: boolean } = {} + // Combine simple field writes (plan, verify_only, verify_required) into one update call + const simpleData: { implementation_plan?: string; verify_only?: boolean; verify_required?: typeof VERIFY_REQUIRED_VALUES[number] } = {} if (parsed.data.implementation_plan !== undefined) simpleData.implementation_plan = parsed.data.implementation_plan if (parsed.data.verify_only !== undefined) simpleData.verify_only = parsed.data.verify_only + if (parsed.data.verify_required !== undefined) + simpleData.verify_required = parsed.data.verify_required const updated = await prisma.$transaction(async (tx) => { const simpleUpdate = Object.keys(simpleData).length > 0 ? await tx.task.update({ where: { id }, data: simpleData, - select: { id: true, status: true, implementation_plan: true, verify_only: true }, + select: { id: true, status: true, implementation_plan: true, verify_only: true, verify_required: true }, }) : null @@ -111,6 +117,7 @@ export async function PATCH( status: result.task.status, implementation_plan: result.task.implementation_plan, verify_only: simpleUpdate?.verify_only, + verify_required: simpleUpdate?.verify_required, } } @@ -125,5 +132,6 @@ export async function PATCH( status: taskStatusToApi(updated.status), implementation_plan: updated.implementation_plan, ...(updated.verify_only !== undefined && { verify_only: updated.verify_only }), + ...(updated.verify_required !== undefined && { verify_required: updated.verify_required }), }) } diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 4524d9b..54ee48e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -26,6 +26,7 @@ export interface SoloTask { 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 diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index 953c614..a7f3147 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -55,16 +55,24 @@ const VERIFY_RESULT_CONFIG: Record type SaveState = 'idle' | 'saving' | 'saved' +const VERIFY_REQUIRED_LABELS: Record = { + ALIGNED: 'Strikt — alleen ALIGNED', + ALIGNED_OR_PARTIAL: 'Standaard — ALIGNED of PARTIAL met uitleg', + ANY: 'Vrij — geen verify-eis', +} + function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDetailContentProps) { - const { updatePlan, updateVerifyOnly } = useSoloStore() + const { updatePlan, updateVerifyOnly, updateVerifyRequired } = useSoloStore() const job = useSoloStore(s => s.claudeJobsByTaskId[task.id]) const connectedWorkers = useSoloStore(s => s.connectedWorkers) const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '') const [localVerifyOnly, setLocalVerifyOnly] = useState(task.verify_only) + const [localVerifyRequired, setLocalVerifyRequired] = useState(task.verify_required) const [saveState, setSaveState] = useState('idle') const [, startTransition] = useTransition() const [jobPending, startJobTransition] = useTransition() const [verifyOnlyPending, startVerifyOnlyTransition] = useTransition() + const [verifyRequiredPending, startVerifyRequiredTransition] = useTransition() const fadeTimer = useRef | null>(null) const savedPlanRef = useRef(task.implementation_plan ?? '') @@ -145,6 +153,32 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe }) } + function handleVerifyRequiredChange(e: React.ChangeEvent) { + if (isDemo) return + const newValue = e.target.value as typeof localVerifyRequired + const prevValue = localVerifyRequired + setLocalVerifyRequired(newValue) + startVerifyRequiredTransition(async () => { + try { + const res = await fetch(`/api/tasks/${task.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ verify_required: newValue }), + }) + if (!res.ok) { + setLocalVerifyRequired(prevValue) + toast.error('Verify-required bijwerken mislukt') + return + } + updateVerifyRequired(task.id, newValue) + } catch { + setLocalVerifyRequired(prevValue) + toast.error('Verify-required bijwerken mislukt') + } + }) + } + return ( <> @@ -220,6 +254,22 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe Alleen verifiëren (niet implementeren) +
+ Verify-gate: + + + +
+
void updatePlan: (taskId: string, plan: string | null) => void updateVerifyOnly: (taskId: string, value: boolean) => void + updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void markPending: (taskId: string) => void clearPending: (taskId: string) => void @@ -112,6 +113,9 @@ export const useSoloStore = create((set, get) => ({ updateVerifyOnly: (taskId, value) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })), + updateVerifyRequired: (taskId, value) => + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })), + markPending: (taskId) => set((s) => { if (s.pendingOps.has(taskId)) return s