From 4814fc91a944a9fbbeedb3fde753f1837422a327 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 19:47:29 +0200 Subject: [PATCH] feat(PBI-59): add Detail/Usage view-switch on /jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits het middenpaneel van de jobs-pagina in twee views (zoals admin/jobs): - **Detail** — alle metadata (status, kind, product, branch, PR, dates, errors, summary, verify-result) plus een kind-aware beschrijving: TASK → implementation_plan, IDEA_GRILL → grill_md, IDEA_MAKE_PLAN → plan_md, PLAN_CHAT → idea.description. - **Usage** — model, tokens (in/uit/cache/totaal), berekende kosten in USD via ModelPrice-tabel, en duur (started→finished). SprintSubTasksPane blijft als sticky header boven beide views. Server action `fetchJobsPageData` haalt nu ook ModelPrices op en selecteert task.{description,implementation_plan} + idea.{description,grill_md,plan_md} zodat de description en costUsd in JobWithRelations gevuld kunnen worden. Co-Authored-By: Claude Opus 4.7 (1M context) --- actions/jobs-page.ts | 75 +++++++++++++++++++++++++---- components/jobs/job-detail-pane.tsx | 58 +++++++++++++++++----- components/jobs/job-usage-pane.tsx | 71 +++++++++++++++++++++++++++ components/jobs/jobs-board.tsx | 25 +++++++++- 4 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 components/jobs/job-usage-pane.tsx diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index ebf811d..6148439 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -20,10 +20,12 @@ export type JobWithRelations = { outputTokens: number | null cacheReadTokens: number | null cacheWriteTokens: number | null + costUsd: number | null branch: string | null prUrl: string | null error: string | null summary: string | null + description: string | null verifyResult: VerifyResult | null startedAt: Date | null finishedAt: Date | null @@ -32,13 +34,13 @@ export type JobWithRelations = { } const JOB_INCLUDE = { - task: { select: { code: true, title: true } }, - idea: { select: { code: true, title: true } }, + task: { select: { code: true, title: true, description: true, implementation_plan: true } }, + idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, product: { select: { name: true } }, sprint_run: { include: { sprint: { select: { sprint_goal: true } } } }, } as const -function mapJob(j: { +type RawJob = { id: string kind: ClaudeJobKind status: ClaudeJobStatus @@ -56,11 +58,61 @@ function mapJob(j: { finished_at: Date | null created_at: Date sprint_run_id: string | null - task: { code: string | null; title: string } | null - idea: { code: string | null; title: string } | null + task: { + code: string | null + title: string + description: string | null + implementation_plan: string | null + } | null + idea: { + code: string | null + title: string + description: string | null + grill_md: string | null + plan_md: string | null + } | null product: { name: string } sprint_run: { sprint: { sprint_goal: string } } | null -}): JobWithRelations { +} + +type PriceRow = { + model_id: string + input_price_per_1m: { toString: () => string } + output_price_per_1m: { toString: () => string } + cache_read_price_per_1m: { toString: () => string } + cache_write_price_per_1m: { toString: () => string } +} + +function pickDescription(j: RawJob): string | null { + switch (j.kind) { + case 'TASK_IMPLEMENTATION': + return j.task?.implementation_plan ?? j.task?.description ?? null + case 'IDEA_GRILL': + return j.idea?.grill_md ?? j.idea?.description ?? null + case 'IDEA_MAKE_PLAN': + return j.idea?.plan_md ?? j.idea?.description ?? null + case 'PLAN_CHAT': + return j.idea?.description ?? null + case 'SPRINT_IMPLEMENTATION': + return null + default: + return null + } +} + +function computeCost(j: RawJob, priceMap: Map): number | null { + if (!j.model_id) return null + const p = priceMap.get(j.model_id) + if (!p || j.input_tokens == null) return null + return ( + ((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 + + ((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 + + ((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 + + ((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000 + ) +} + +function mapJob(j: RawJob, priceMap: Map): JobWithRelations { return { id: j.id, kind: j.kind, @@ -77,10 +129,12 @@ function mapJob(j: { outputTokens: j.output_tokens, cacheReadTokens: j.cache_read_tokens, cacheWriteTokens: j.cache_write_tokens, + costUsd: computeCost(j, priceMap), branch: j.branch, prUrl: j.pr_url, error: j.error, summary: j.summary, + description: pickDescription(j), verifyResult: j.verify_result, startedAt: j.started_at, finishedAt: j.finished_at, @@ -93,7 +147,7 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation const session = await getSession() if (!session.userId) return null - const [active, done] = await Promise.all([ + const [active, done, prices] = await Promise.all([ prisma.claudeJob.findMany({ where: { user_id: session.userId, status: { notIn: ['DONE'] } }, include: JOB_INCLUDE, @@ -105,10 +159,13 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation orderBy: { finished_at: 'desc' }, take: 100, }), + prisma.modelPrice.findMany(), ]) + const priceMap = new Map(prices.map((p) => [p.model_id, p as unknown as PriceRow])) + return { - activeJobs: active.map(mapJob), - doneJobs: done.map(mapJob), + activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)), + doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)), } } diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index 5f7b2cd..c90f220 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -19,6 +19,24 @@ function FieldRow({ label, children }: FieldRowProps) { ) } +function subjectLabel(job: JobWithRelations): { label: string; value: string } | null { + switch (job.kind) { + case 'TASK_IMPLEMENTATION': + if (!job.taskTitle) return null + return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle } + case 'SPRINT_IMPLEMENTATION': + if (!job.sprintGoal) return null + return { label: 'Sprint', value: job.sprintGoal } + case 'IDEA_GRILL': + case 'IDEA_MAKE_PLAN': + case 'PLAN_CHAT': + if (!job.ideaTitle) return null + return { label: 'Idee', value: job.ideaCode ? `${job.ideaCode} ${job.ideaTitle}` : job.ideaTitle } + default: + return null + } +} + interface JobDetailPaneProps { job: JobWithRelations | null } @@ -33,6 +51,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { } const apiStatus = jobStatusToApi(job.status) + const subject = subjectLabel(job) return (
@@ -43,11 +62,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { {job.kind} {job.productName} - {job.modelId || '—'} - {job.inputTokens?.toLocaleString() || '—'} - {job.outputTokens?.toLocaleString() || '—'} - {job.cacheReadTokens?.toLocaleString() || '—'} - {job.cacheWriteTokens?.toLocaleString() || '—'} + {subject && {subject.value}} {job.branch || '—'} @@ -58,12 +73,9 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { ) : '—'} - - {job.error ? ( -
-            {job.error}
-          
- ) : '—'} + {job.verifyResult ?? '—'} + + {new Date(job.createdAt).toLocaleString('nl-NL')} {job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'} @@ -71,6 +83,30 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { {job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'} + + {job.error ? ( +
+            {job.error}
+          
+ ) : '—'} +
+ + {job.summary ? ( +
+            {job.summary}
+          
+ ) : '—'} +
+
+

Beschrijving

+ {job.description ? ( +
+            {job.description}
+          
+ ) : ( +

Geen beschrijving.

+ )} +
) } diff --git a/components/jobs/job-usage-pane.tsx b/components/jobs/job-usage-pane.tsx new file mode 100644 index 0000000..2a5cd1e --- /dev/null +++ b/components/jobs/job-usage-pane.tsx @@ -0,0 +1,71 @@ +'use client' + +import type { JobWithRelations } from '@/actions/jobs-page' + +interface FieldRowProps { + label: string + children: React.ReactNode +} + +function FieldRow({ label, children }: FieldRowProps) { + return ( +
+ {label} + {children} +
+ ) +} + +function formatNumber(n: number | null | undefined): string { + return n != null ? n.toLocaleString('nl-NL') : '—' +} + +function formatDuration(start: Date | null, end: Date | null): string { + if (!start) return '—' + const endTime = end ? new Date(end).getTime() : Date.now() + const ms = endTime - new Date(start).getTime() + if (ms < 0) return '—' + const sec = Math.floor(ms / 1000) + if (sec < 60) return `${sec}s` + const min = Math.floor(sec / 60) + const remSec = sec % 60 + if (min < 60) return `${min}m ${remSec}s` + const hr = Math.floor(min / 60) + const remMin = min % 60 + return `${hr}u ${remMin}m` +} + +interface JobUsagePaneProps { + job: JobWithRelations | null +} + +export default function JobUsagePane({ job }: JobUsagePaneProps) { + if (!job) { + return ( +
+ Selecteer een job om gebruik te zien +
+ ) + } + + const totalTokens = + (job.inputTokens ?? 0) + + (job.outputTokens ?? 0) + + (job.cacheReadTokens ?? 0) + + (job.cacheWriteTokens ?? 0) + + const costLabel = job.costUsd != null ? `$${job.costUsd.toFixed(4)}` : '—' + + return ( +
+ {job.modelId ?? '—'} + {formatNumber(job.inputTokens)} + {formatNumber(job.outputTokens)} + {formatNumber(job.cacheReadTokens)} + {formatNumber(job.cacheWriteTokens)} + {formatNumber(totalTokens || null)} + {costLabel} + {formatDuration(job.startedAt, job.finishedAt)} +
+ ) +} diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 11c84ff..498ba60 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -1,9 +1,11 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' import { SplitPane } from '@/components/split-pane/split-pane' import JobCard from './job-card' import JobDetailPane from './job-detail-pane' +import JobUsagePane from './job-usage-pane' import SprintSubTasksPane from './sprint-sub-tasks-pane' import { useJobsStore } from '@/stores/jobs-store' import useJobsRealtime from '@/hooks/use-jobs-realtime' @@ -14,6 +16,8 @@ interface JobsBoardProps { initialDoneJobs: JobWithRelations[] } +type View = 'detail' | 'usage' + function jobToCardProps(j: JobWithRelations) { return { id: j.id, @@ -34,6 +38,7 @@ function jobToCardProps(j: JobWithRelations) { export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() + const [view, setView] = useState('detail') useJobsRealtime() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -63,8 +68,24 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo jobId={selectedJobId} isSprintJob={selectedJob?.kind === 'SPRINT_IMPLEMENTATION'} /> +
+ + +
- + {view === 'detail' ? : }
)