From f166186374aefac70e166705f3681be19e0f3e11 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 19:16:20 +0200 Subject: [PATCH] feat(PBI-59): Jobs-pagina UI (vervolg na #149) (#150) * feat(PBI-58): Vitest-tests voor SoloTaskCard veldmapping en 4-regels layout Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): server action fetchJobsPageData voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): SSE-route /api/realtime/jobs voor user-scoped job-events Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobCard component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobDetailPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): API route GET /api/jobs/[id]/sub-tasks voor sprint task executions Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): SprintSubTasksPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): Zustand store useJobsStore voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): useJobsRealtime hook met SSE-verbinding en store-updates Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobsBoard 3-kolom SplitPane client component Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): /jobs server page met JobsBoard Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): Jobs nav-link toevoegen aan NavBar Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/(app)/jobs/page.tsx | 25 ++++++ components/jobs/jobs-board.tsx | 96 +++++++++++++++++++++++ components/jobs/sprint-sub-tasks-pane.tsx | 67 ++++++++++++++++ components/shared/nav-bar.tsx | 1 + hooks/use-jobs-realtime.ts | 79 +++++++++++++++++++ stores/jobs-store.ts | 59 ++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 app/(app)/jobs/page.tsx create mode 100644 components/jobs/jobs-board.tsx create mode 100644 components/jobs/sprint-sub-tasks-pane.tsx create mode 100644 hooks/use-jobs-realtime.ts create mode 100644 stores/jobs-store.ts 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) + } + } + }) + }, + })) +)