diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx
new file mode 100644
index 0000000..3982bff
--- /dev/null
+++ b/app/(app)/jobs/page.tsx
@@ -0,0 +1,25 @@
+import { redirect } from 'next/navigation'
+import { getSession } from '@/lib/auth'
+import { fetchJobsPageData } from '@/actions/jobs-page'
+import JobsBoard from '@/components/jobs/jobs-board'
+
+export const metadata = { title: 'Jobs — Scrum4Me' }
+
+export default async function JobsPage() {
+ const session = await getSession()
+ if (!session.userId) redirect('/login')
+
+ const data = await fetchJobsPageData()
+ if (!data) redirect('/login')
+
+ return (
+
+
+
Jobs
+
+
+
+
+
+ )
+}
diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx
new file mode 100644
index 0000000..11c84ff
--- /dev/null
+++ b/components/jobs/jobs-board.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import { useEffect } from 'react'
+import { SplitPane } from '@/components/split-pane/split-pane'
+import JobCard from './job-card'
+import JobDetailPane from './job-detail-pane'
+import SprintSubTasksPane from './sprint-sub-tasks-pane'
+import { useJobsStore } from '@/stores/jobs-store'
+import useJobsRealtime from '@/hooks/use-jobs-realtime'
+import type { JobWithRelations } from '@/actions/jobs-page'
+
+interface JobsBoardProps {
+ initialActiveJobs: JobWithRelations[]
+ initialDoneJobs: JobWithRelations[]
+}
+
+function jobToCardProps(j: JobWithRelations) {
+ return {
+ id: j.id,
+ kind: j.kind,
+ status: j.status,
+ taskCode: j.taskCode,
+ taskTitle: j.taskTitle,
+ ideaCode: j.ideaCode,
+ ideaTitle: j.ideaTitle,
+ sprintGoal: j.sprintGoal,
+ sprintCode: j.sprintCode,
+ productName: j.productName,
+ branch: j.branch,
+ error: j.error,
+ summary: j.summary,
+ }
+}
+
+export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) {
+ const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore()
+ useJobsRealtime()
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(() => { initJobs(initialActiveJobs, initialDoneJobs) }, [])
+
+ const selectedJob = [...activeJobs, ...doneJobs].find(j => j.id === selectedJobId) ?? null
+
+ const leftPane = (
+
+ {activeJobs.map(j => (
+
setSelectedJobId(j.id)}
+ />
+ ))}
+ {activeJobs.length === 0 && (
+ Geen actieve jobs
+ )}
+
+ )
+
+ const middlePane = (
+
+ )
+
+ const rightPane = (
+
+ {doneJobs.map(j => (
+
setSelectedJobId(j.id)}
+ />
+ ))}
+ {doneJobs.length === 0 && (
+ Nog geen afgeronde jobs
+ )}
+
+ )
+
+ return (
+
+ )
+}
diff --git a/components/jobs/sprint-sub-tasks-pane.tsx b/components/jobs/sprint-sub-tasks-pane.tsx
new file mode 100644
index 0000000..7c91193
--- /dev/null
+++ b/components/jobs/sprint-sub-tasks-pane.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { cn } from '@/lib/utils'
+import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status'
+import type { ClaudeJobStatusApi } from '@/lib/job-status'
+
+type SubTask = {
+ id: string
+ taskCode: string | null
+ taskTitle: string
+ status: string
+}
+
+interface SprintSubTasksPaneProps {
+ jobId: string | null
+ isSprintJob: boolean
+}
+
+function SubTaskList({ jobId }: { jobId: string }) {
+ const [subTasks, setSubTasks] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ const controller = new AbortController()
+
+ fetch(`/api/jobs/${jobId}/sub-tasks`, { signal: controller.signal })
+ .then(res => res.json())
+ .then((data: SubTask[]) => {
+ setSubTasks(data)
+ setLoading(false)
+ })
+ .catch(() => {
+ setLoading(false)
+ })
+
+ return () => controller.abort()
+ }, [jobId])
+
+ if (loading) {
+ return Laden…
+ }
+
+ if (subTasks.length === 0) return null
+
+ return (
+
+ {subTasks.map(t => {
+ const apiStatus = t.status.toLowerCase() as ClaudeJobStatusApi
+ return (
+
+ {t.taskCode}
+ {t.taskTitle}
+
+ {JOB_STATUS_LABELS[apiStatus] ?? t.status}
+
+
+ )
+ })}
+
+ )
+}
+
+export default function SprintSubTasksPane({ jobId, isSprintJob }: SprintSubTasksPaneProps) {
+ if (!isSprintJob || !jobId) return null
+ return
+}
diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx
index 2ff3a81..61365b4 100644
--- a/components/shared/nav-bar.tsx
+++ b/components/shared/nav-bar.tsx
@@ -143,6 +143,7 @@ export function NavBar({
: disabledSpan('Solo')}
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
{navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))}
+ {navLink('/jobs', 'Jobs', pathname.startsWith('/jobs'))}
{navLink('/manual', 'Manual', pathname.startsWith('/manual'))}
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
diff --git a/hooks/use-jobs-realtime.ts b/hooks/use-jobs-realtime.ts
new file mode 100644
index 0000000..f85b5c5
--- /dev/null
+++ b/hooks/use-jobs-realtime.ts
@@ -0,0 +1,79 @@
+import { useEffect } from 'react'
+import { useJobsStore } from '@/stores/jobs-store'
+import type { ClaudeJobStatus } from '@prisma/client'
+
+interface JobStatusPayload {
+ job_id: string
+ kind?: string
+ status: string
+ task_id?: string | null
+ idea_id?: string | null
+ sprint_run_id?: string | null
+ branch?: string
+ pushed_at?: string
+ pr_url?: string
+ verify_result?: string
+ summary?: string
+ error?: string
+}
+
+export default function useJobsRealtime() {
+ const initJobs = useJobsStore(s => s.initJobs)
+ const upsertJob = useJobsStore(s => s.upsertJob)
+
+ useEffect(() => {
+ let es: EventSource | null = null
+ let reconnectTimer: ReturnType | null = null
+ let active = true
+
+ function connect() {
+ if (!active) return
+
+ es = new EventSource('/api/realtime/jobs')
+
+ es.addEventListener('jobs_initial', (event) => {
+ try {
+ const jobs = JSON.parse(event.data)
+ if (Array.isArray(jobs)) {
+ initJobs(jobs, useJobsStore.getState().doneJobs)
+ }
+ } catch {
+ // malformed JSON
+ }
+ })
+
+ es.addEventListener('message', (event) => {
+ try {
+ const payload = JSON.parse(event.data) as JobStatusPayload
+ if (!payload.job_id) return
+ upsertJob({
+ id: payload.job_id,
+ status: payload.status as ClaudeJobStatus,
+ branch: payload.branch ?? null,
+ prUrl: payload.pr_url ?? null,
+ error: payload.error ?? null,
+ summary: payload.summary ?? null,
+ })
+ } catch {
+ // malformed JSON
+ }
+ })
+
+ es.onerror = () => {
+ es?.close()
+ es = null
+ if (active) {
+ reconnectTimer = setTimeout(connect, 3000)
+ }
+ }
+ }
+
+ connect()
+
+ return () => {
+ active = false
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ es?.close()
+ }
+ }, [initJobs, upsertJob])
+}
diff --git a/stores/jobs-store.ts b/stores/jobs-store.ts
new file mode 100644
index 0000000..fe0cc40
--- /dev/null
+++ b/stores/jobs-store.ts
@@ -0,0 +1,59 @@
+import { create } from 'zustand'
+import { immer } from 'zustand/middleware/immer'
+import type { JobWithRelations } from '@/actions/jobs-page'
+
+type JobsState = {
+ activeJobs: JobWithRelations[]
+ doneJobs: JobWithRelations[]
+ selectedJobId: string | null
+}
+
+type JobsActions = {
+ initJobs(active: JobWithRelations[], done: JobWithRelations[]): void
+ setSelectedJobId(id: string | null): void
+ upsertJob(job: Partial & { id: string; status: string }): void
+}
+
+export const useJobsStore = create()(
+ immer((set) => ({
+ activeJobs: [],
+ doneJobs: [],
+ selectedJobId: null,
+
+ initJobs(active, done) {
+ set((state) => {
+ state.activeJobs = active
+ state.doneJobs = done
+ })
+ },
+
+ setSelectedJobId(id) {
+ set((state) => {
+ state.selectedJobId = id
+ })
+ },
+
+ upsertJob(job) {
+ set((state) => {
+ const isDone = job.status.toUpperCase() === 'DONE'
+
+ if (isDone) {
+ state.activeJobs = state.activeJobs.filter(j => j.id !== job.id)
+ if (!state.doneJobs.find(j => j.id === job.id)) {
+ state.doneJobs.unshift(job as JobWithRelations)
+ if (state.doneJobs.length > 100) {
+ state.doneJobs = state.doneJobs.slice(0, 100)
+ }
+ }
+ } else {
+ const idx = state.activeJobs.findIndex(j => j.id === job.id)
+ if (idx !== -1) {
+ Object.assign(state.activeJobs[idx], job)
+ } else {
+ state.activeJobs.push(job as JobWithRelations)
+ }
+ }
+ })
+ },
+ }))
+)