diff --git a/__tests__/components/jobs/job-detail-pane.test.tsx b/__tests__/components/jobs/job-detail-pane.test.tsx new file mode 100644 index 0000000..9e51f87 --- /dev/null +++ b/__tests__/components/jobs/job-detail-pane.test.tsx @@ -0,0 +1,75 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import '@testing-library/jest-dom' +import type { JobWithRelations } from '@/actions/jobs-page' + +vi.mock('@/actions/claude-jobs', () => ({ + restartClaudeJobAction: vi.fn(), +})) + +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +import { restartClaudeJobAction } from '@/actions/claude-jobs' +import JobDetailPane from '@/components/jobs/job-detail-pane' + +const mockAction = restartClaudeJobAction as ReturnType + +function makeJob(status: JobWithRelations['status']): JobWithRelations { + return { + id: 'job-1', + kind: 'TASK_IMPLEMENTATION', + status, + taskCode: 'T-1', + taskTitle: 'Test taak', + ideaCode: null, + ideaTitle: null, + sprintGoal: null, + sprintCode: null, + productName: 'Scrum4Me', + modelId: null, + inputTokens: null, + outputTokens: null, + cacheReadTokens: null, + cacheWriteTokens: null, + costUsd: null, + branch: null, + prUrl: null, + error: null, + summary: null, + description: null, + verifyResult: null, + startedAt: null, + finishedAt: null, + createdAt: new Date('2026-01-01'), + sprintRunId: null, + } +} + +beforeEach(() => { + vi.clearAllMocks() + mockAction.mockResolvedValue({ success: true }) +}) + +describe('JobDetailPane restart button', () => { + it('toont de knop voor FAILED-jobs', () => { + render() + expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument() + }) + + it('toont de knop niet voor DONE-jobs', () => { + render() + expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument() + }) + + it('roept restartClaudeJobAction aan met het juiste id bij klik', () => { + render() + fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i })) + expect(mockAction).toHaveBeenCalledWith('job-1') + }) + + it('knop is disabled in demo-modus', () => { + render() + expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled() + }) +}) diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx index 3982bff..3731e6c 100644 --- a/app/(app)/jobs/page.tsx +++ b/app/(app)/jobs/page.tsx @@ -18,7 +18,7 @@ export default async function JobsPage() {

Jobs

- +
) diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index 7a691c1..9063113 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -1,9 +1,16 @@ 'use client' +import { useTransition } from 'react' +import { toast } from 'sonner' import { cn } from '@/lib/utils' import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' import { jobStatusToApi } from '@/lib/job-status' import type { JobWithRelations } from '@/actions/jobs-page' +import { Button } from '@/components/ui/button' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { restartClaudeJobAction } from '@/actions/claude-jobs' + +const RESTARTABLE_API_STATUSES = new Set(['failed', 'cancelled', 'skipped']) interface FieldRowProps { label: string @@ -42,9 +49,12 @@ function subjectLabel(job: JobWithRelations): { label: string; value: string } | interface JobDetailPaneProps { job: JobWithRelations | null + isDemo: boolean } -export default function JobDetailPane({ job }: JobDetailPaneProps) { +export default function JobDetailPane({ job, isDemo }: JobDetailPaneProps) { + const [isPending, startTransition] = useTransition() + if (!job) { return (
@@ -55,6 +65,14 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { const apiStatus = jobStatusToApi(job.status) const subject = subjectLabel(job) + const canRestart = RESTARTABLE_API_STATUSES.has(apiStatus) + + function handleRestart() { + startTransition(async () => { + const result = await restartClaudeJobAction(job!.id) + if ('error' in result) toast.error(result.error) + }) + } return (
@@ -110,6 +128,19 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) {

Geen beschrijving.

)}
+ {canRestart && ( +
+ + + +
+ )}
) } diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 6fd3024..f416e6b 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -15,6 +15,7 @@ import type { JobWithRelations } from '@/actions/jobs-page' interface JobsBoardProps { initialActiveJobs: JobWithRelations[] initialDoneJobs: JobWithRelations[] + isDemo: boolean } type View = 'detail' | 'usage' @@ -32,7 +33,7 @@ const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = { value: 'skipped', label: 'Overgeslagen' }, ] -export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { +export default function JobsBoard({ initialActiveJobs, initialDoneJobs, isDemo }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() const [view, setView] = useState('detail') useJobsRealtime() @@ -77,7 +78,7 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo
- {view === 'detail' ? : } + {view === 'detail' ? : }
) diff --git a/docs/specs/functional.md b/docs/specs/functional.md index e649ee3..436fafe 100644 --- a/docs/specs/functional.md +++ b/docs/specs/functional.md @@ -522,6 +522,30 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo --- +### F-14: Job-queue inzicht en beheer (`/jobs`) + +**Prioriteit:** v1 — Operationele controle +**Persona:** Lars + +**Omschrijving:** +De `/jobs`-pagina geeft een overzicht van alle `ClaudeJob`-records voor het actieve product. Vanuit de `JobDetailPane` kan de gebruiker een mislukte, geannuleerde of overgeslagen job opnieuw in de wachtrij zetten. + +**Acceptatiecriteria:** + +#### Mislukte job opnieuw starten + +- [ ] Een `ClaudeJob` in status `FAILED`, `CANCELLED` of `SKIPPED` toont een "Opnieuw starten"-knop in de `JobDetailPane`. +- [ ] De knop reset de bestaande job (geen nieuwe job aanmaken): `status → QUEUED`, `retry_count + 1`, alle run-velden gecleared. +- [ ] Bij `SPRINT_IMPLEMENTATION`-jobs worden alle bijbehorende `SprintTaskExecution`-rows in dezelfde transactie teruggezet naar `PENDING`. +- [ ] Tijdens de server-action is de knop disabled (loading-state). De UI updatet via SSE zonder handmatige refresh. +- [ ] Demo-sessies zien een `DemoTooltip` op de knop en kunnen niet restarten (drie-laagse policy: knop disabled + server action `session.isDemo`-check + HTTP 403). + +**Randgevallen:** +- Job is ondertussen al door een andere actie opnieuw gestart (race condition) → server-action controleert de huidige status vóór de update; als de status niet meer `FAILED/CANCELLED/SKIPPED` is, retourneert de action een foutmelding. +- Demo-token probeert via directe API-aanroep te restarten → 403 Forbidden. + +--- + ## Navigatiestructuur ```