* feat(PBI-58): Vitest-tests voor SoloTaskCard veldmapping en 4-regels layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): server action fetchJobsPageData voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): SSE-route /api/realtime/jobs voor user-scoped job-events Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): JobCard component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): JobDetailPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): API route GET /api/jobs/[id]/sub-tasks voor sprint task executions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): SprintSubTasksPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): Zustand store useJobsStore voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): useJobsRealtime hook met SSE-verbinding en store-updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): JobsBoard 3-kolom SplitPane client component Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): /jobs server page met JobsBoard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(PBI-59): Jobs nav-link toevoegen aan NavBar Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4a63b4b01f
commit
f166186374
6 changed files with 327 additions and 0 deletions
25
app/(app)/jobs/page.tsx
Normal file
25
app/(app)/jobs/page.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b shrink-0 flex items-center gap-3">
|
||||||
|
<h1 className="text-lg font-semibold">Jobs</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
96
components/jobs/jobs-board.tsx
Normal file
96
components/jobs/jobs-board.tsx
Normal file
|
|
@ -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 = (
|
||||||
|
<div className="overflow-y-auto h-full p-2 space-y-2">
|
||||||
|
{activeJobs.map(j => (
|
||||||
|
<JobCard
|
||||||
|
key={j.id}
|
||||||
|
{...jobToCardProps(j)}
|
||||||
|
isSelected={j.id === selectedJobId}
|
||||||
|
onClick={() => setSelectedJobId(j.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{activeJobs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">Geen actieve jobs</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const middlePane = (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<SprintSubTasksPane
|
||||||
|
jobId={selectedJobId}
|
||||||
|
isSprintJob={selectedJob?.kind === 'SPRINT_IMPLEMENTATION'}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<JobDetailPane job={selectedJob} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const rightPane = (
|
||||||
|
<div className="overflow-y-auto h-full p-2 space-y-2">
|
||||||
|
{doneJobs.map(j => (
|
||||||
|
<JobCard
|
||||||
|
key={j.id}
|
||||||
|
{...jobToCardProps(j)}
|
||||||
|
isSelected={j.id === selectedJobId}
|
||||||
|
onClick={() => setSelectedJobId(j.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{doneJobs.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">Nog geen afgeronde jobs</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitPane
|
||||||
|
panes={[leftPane, middlePane, rightPane]}
|
||||||
|
defaultSplit={[25, 50, 25]}
|
||||||
|
cookieKey="jobs"
|
||||||
|
tabLabels={['Actief', 'Details', 'Klaar']}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
components/jobs/sprint-sub-tasks-pane.tsx
Normal file
67
components/jobs/sprint-sub-tasks-pane.tsx
Normal file
|
|
@ -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<SubTask[]>([])
|
||||||
|
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 <div className="text-xs text-muted-foreground p-3">Laden…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subTasks.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b p-2 space-y-1 max-h-44 overflow-y-auto shrink-0">
|
||||||
|
{subTasks.map(t => {
|
||||||
|
const apiStatus = t.status.toLowerCase() as ClaudeJobStatusApi
|
||||||
|
return (
|
||||||
|
<div key={t.id} className="flex items-center gap-2 py-1 px-2 rounded hover:bg-surface-container text-sm">
|
||||||
|
<span className="text-xs font-mono text-muted-foreground w-16 shrink-0 truncate">{t.taskCode}</span>
|
||||||
|
<span className="flex-1 truncate">{t.taskTitle}</span>
|
||||||
|
<span className={cn('text-xs px-1.5 py-0.5 rounded-full border', JOB_STATUS_COLORS[apiStatus])}>
|
||||||
|
{JOB_STATUS_LABELS[apiStatus] ?? t.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SprintSubTasksPane({ jobId, isSprintJob }: SprintSubTasksPaneProps) {
|
||||||
|
if (!isSprintJob || !jobId) return null
|
||||||
|
return <SubTaskList key={jobId} jobId={jobId} />
|
||||||
|
}
|
||||||
|
|
@ -143,6 +143,7 @@ export function NavBar({
|
||||||
: disabledSpan('Solo')}
|
: disabledSpan('Solo')}
|
||||||
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||||
{navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))}
|
{navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))}
|
||||||
|
{navLink('/jobs', 'Jobs', pathname.startsWith('/jobs'))}
|
||||||
{navLink('/manual', 'Manual', pathname.startsWith('/manual'))}
|
{navLink('/manual', 'Manual', pathname.startsWith('/manual'))}
|
||||||
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
|
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
79
hooks/use-jobs-realtime.ts
Normal file
79
hooks/use-jobs-realtime.ts
Normal file
|
|
@ -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<typeof setTimeout> | 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])
|
||||||
|
}
|
||||||
59
stores/jobs-store.ts
Normal file
59
stores/jobs-store.ts
Normal file
|
|
@ -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<JobWithRelations> & { id: string; status: string }): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useJobsStore = create<JobsState & JobsActions>()(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue