diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts
index 2ba3025..0a9b5df 100644
--- a/__tests__/api/story-log.test.ts
+++ b/__tests__/api/story-log.test.ts
@@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => {
const res = await postStoryLog(
...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' })
)
- const data = await res.json()
+ await res.json()
expect(res.status).toBe(201)
expect(mockPrisma.storyLog.create).toHaveBeenCalledWith(
diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts
index 75b15fa..12fa3e9 100644
--- a/actions/claude-jobs.ts
+++ b/actions/claude-jobs.ts
@@ -3,9 +3,7 @@
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
-import { productAccessFilter } from '@/lib/product-access'
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
-import { enforceUserRateLimit } from '@/lib/rate-limit'
type EnqueueResult =
| { success: true; jobId: string }
diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx
index b1c9920..76d0825 100644
--- a/app/(app)/admin/jobs/page.tsx
+++ b/app/(app)/admin/jobs/page.tsx
@@ -16,15 +16,34 @@ export default async function AdminJobsPage() {
branch: true,
pr_url: true,
error: true,
+ model_id: true,
+ input_tokens: true,
+ output_tokens: true,
+ cache_read_tokens: true,
+ cache_write_tokens: true,
user: { select: { username: true } },
product: { select: { name: true } },
},
})
+ const prices = await prisma.modelPrice.findMany()
+ const priceMap = new Map(prices.map((p) => [p.model_id, p]))
+
+ const jobsWithCost = jobs.map((job) => {
+ const p = job.model_id ? priceMap.get(job.model_id) : undefined
+ if (!p || job.input_tokens == null) return { ...job, cost_usd: null }
+ const cost =
+ (job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 +
+ (job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 +
+ (job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 +
+ (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000
+ return { ...job, cost_usd: cost }
+ })
+
return (
Claude Jobs
-
+
)
}
diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx
index a241549..3b1c312 100644
--- a/components/admin/jobs-table.tsx
+++ b/components/admin/jobs-table.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useTransition } from 'react'
+import { useState, useTransition } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
@@ -23,6 +23,8 @@ type Job = {
branch: string | null
pr_url: string | null
error: string | null
+ model_id: string | null
+ cost_usd: number | null
}
const STATUS_CLASS: Record = {
@@ -92,7 +94,7 @@ function JobRow({ job }: { job: Job }) {
)
}
-export function JobsTable({ jobs }: { jobs: Job[] }) {
+function StatusTable({ jobs }: { jobs: Job[] }) {
return (
@@ -123,3 +125,86 @@ export function JobsTable({ jobs }: { jobs: Job[] }) {
)
}
+
+function CostRow({ job }: { job: Job }) {
+ const [pending, startTransition] = useTransition()
+ function handleCancel() { startTransition(() => cancelJobAction(job.id)) }
+ function handleDelete() { startTransition(() => deleteJobAction(job.id)) }
+ const costLabel = job.cost_usd != null ? `$${job.cost_usd.toFixed(4)}` : '—'
+ return (
+
+ {job.id.slice(0, 8)}
+ {job.user.username}
+ {job.product.name}
+ {KIND_LABEL[job.kind] ?? job.kind}
+ {job.model_id ?? '—'}
+ {costLabel}
+
+ {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
+
+
+
+ {ACTIVE_STATUSES.has(job.status) && (
+
+ )}
+
+
+
+
+ )
+}
+
+function CostsTable({ jobs }: { jobs: Job[] }) {
+ return (
+
+
+
+ ID
+ Gebruiker
+ Product
+ Type
+ Model
+ Kosten (USD)
+ Aangemaakt
+ Acties
+
+
+
+ {jobs.length === 0 && (
+
+
+ Geen jobs gevonden
+
+
+ )}
+ {jobs.map((job) => )}
+
+
+ )
+}
+
+export function JobsTable({ jobs }: { jobs: Job[] }) {
+ const [view, setView] = useState<'status' | 'costs'>('status')
+
+ return (
+
+
+
+
+
+ {view === 'status' ?
:
}
+
+ )
+}
diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx
index a0a9797..a478fb9 100644
--- a/components/dialogs/product-dialog.tsx
+++ b/components/dialogs/product-dialog.tsx
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
-import { useForm } from 'react-hook-form'
+import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
@@ -150,7 +150,7 @@ export function ProductDialog(props: Props) {
}
}
- const autoPr = form.watch('auto_pr')
+ const autoPr = useWatch({ control: form.control, name: 'auto_pr' })
return (
<>
diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx
index e8e3916..adbb857 100644
--- a/components/ideas/idea-detail-layout.tsx
+++ b/components/ideas/idea-detail-layout.tsx
@@ -210,9 +210,10 @@ export function IdeaDetailLayout({
{t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && (
●
)}
- {t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? (
+ {t.key === 'timeline' &&
+ (logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? (
- ({logs.length + questions.length})
+ ({logs.length + questions.length + userQuestions.length})
) : null}
@@ -249,7 +250,16 @@ export function IdeaDetailLayout({
ideaId={idea.id}
/>
)}
- {tab === 'timeline' && }
+ {tab === 'timeline' && (
+
+ )}
{tab === 'sync' && showSync && syncData && }
)
diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx
index 133e176..c6265d6 100644
--- a/components/ideas/idea-timeline.tsx
+++ b/components/ideas/idea-timeline.tsx
@@ -1,12 +1,13 @@
'use client'
-// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion entries.
+// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion + UserQuestion entries.
// Server-component zou ook kunnen, maar we mounten dit binnen de client-side
// detail-layout dus client is simpler (geen rsc-boundary doorbreken).
//
// Iconen + kleur per log-type voor snelle herkenning.
-// Open questions krijgen een inline answer-form (M12 hotfix — zie
-// notifications-bell-pad in M11; voor idee-vragen blijft de bel buiten beeld).
+// Open ClaudeQuestions krijgen een inline answer-form (M11).
+// PBI-33: UserQuestions tonen vraag + (indien beantwoord) Claude's antwoord.
+// Onderaan: UserChatInput om nieuwe vraag te stellen (alleen als plan_md aanwezig is).
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
@@ -15,6 +16,7 @@ import {
FileText,
HelpCircle,
Lightbulb,
+ MessageCircle,
RefreshCw,
StickyNote,
Wrench,
@@ -24,6 +26,7 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { answerQuestion } from '@/actions/questions'
+import { UserChatInput } from '@/components/ideas/user-chat-input'
import type { IdeaLogType } from '@prisma/client'
@@ -45,9 +48,21 @@ export interface TimelineQuestion {
expires_at: string
}
+export interface TimelineUserQuestion {
+ id: string
+ question: string
+ answer: string | null
+ status: 'pending' | 'answered'
+ created_at: string
+}
+
interface Props {
logs: TimelineLog[]
questions: TimelineQuestion[]
+ userQuestions: TimelineUserQuestion[]
+ planMd: string | null
+ ideaId: string
+ isDemo?: boolean
}
const LOG_ICON: Record = {
@@ -75,7 +90,19 @@ const QUESTION_STATUS_LABEL: Record = {
expired: 'Verlopen',
}
-export function IdeaTimeline({ logs, questions }: Props) {
+const USER_QUESTION_STATUS_LABEL: Record = {
+ pending: 'In behandeling',
+ answered: 'Beantwoord',
+}
+
+export function IdeaTimeline({
+ logs,
+ questions,
+ userQuestions,
+ planMd,
+ ideaId,
+ isDemo = false,
+}: Props) {
const merged = [
...logs.map((l) => ({
kind: 'log' as const,
@@ -87,98 +114,143 @@ export function IdeaTimeline({ logs, questions }: Props) {
created_at: q.created_at,
data: q,
})),
+ ...userQuestions.map((uq) => ({
+ kind: 'user_question' as const,
+ created_at: uq.created_at,
+ data: uq,
+ })),
].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
- if (merged.length === 0) {
- return (
-
- Nog geen activiteit op dit idee.
-
- )
- }
+ const showChatInput = planMd !== null
return (
-
- {merged.map((entry, i) => {
- // Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen
- // (server-locale verschilde van browser-locale).
- const time = new Date(entry.created_at).toLocaleString('nl-NL', {
- dateStyle: 'short',
- timeStyle: 'short',
- })
+
+ {merged.length === 0 ? (
+
+ Nog geen activiteit op dit idee.
+
+ ) : (
+
+ {merged.map((entry, i) => {
+ // Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen
+ // (server-locale verschilde van browser-locale).
+ const time = new Date(entry.created_at).toLocaleString('nl-NL', {
+ dateStyle: 'short',
+ timeStyle: 'short',
+ })
- if (entry.kind === 'log') {
- const type = entry.data.type as IdeaLogType
- return (
- -
-
- {LOG_ICON[type] ?? }
-
-
-
-
- {LOG_LABEL[type] ?? type}
+ if (entry.kind === 'log') {
+ const type = entry.data.type as IdeaLogType
+ return (
+ -
+
+ {LOG_ICON[type] ?? }
- ·
-
-
-
{entry.data.content}
- {entry.data.metadata != null &&
- typeof entry.data.metadata === 'object' &&
- Object.keys(entry.data.metadata as object).length > 0 ? (
-
- metadata
-
- {JSON.stringify(entry.data.metadata, null, 2)}
-
-
- ) : null}
-
-
- )
- }
-
- const q = entry.data
- return (
- -
-
-
-
-
-
- Vraag
- ·
- {QUESTION_STATUS_LABEL[q.status]}
- ·
-
-
-
{q.question}
- {q.status === 'open' ? (
-
- ) : (
- <>
- {q.options && q.options.length > 0 ? (
-
- {q.options.map((o, ii) => (
- - {o}
- ))}
-
- ) : null}
- {q.answer ? (
-
-
- Antwoord
+
+
+
+ {LOG_LABEL[type] ?? type}
- {q.answer}
+ ·
+
+
+
{entry.data.content}
+ {entry.data.metadata != null &&
+ typeof entry.data.metadata === 'object' &&
+ Object.keys(entry.data.metadata as object).length > 0 ? (
+
+ metadata
+
+ {JSON.stringify(entry.data.metadata, null, 2)}
+
+
+ ) : null}
+
+
+ )
+ }
+
+ if (entry.kind === 'question') {
+ const q = entry.data
+ return (
+ -
+
+
+
+
+
+ Vraag
+ ·
+ {QUESTION_STATUS_LABEL[q.status]}
+ ·
+
+
+
{q.question}
+ {q.status === 'open' ? (
+
+ ) : (
+ <>
+ {q.options && q.options.length > 0 ? (
+
+ {q.options.map((o, ii) => (
+ - {o}
+ ))}
+
+ ) : null}
+ {q.answer ? (
+
+
+ Antwoord
+
+ {q.answer}
+
+ ) : null}
+ >
+ )}
+
+
+ )
+ }
+
+ // user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT)
+ const uq = entry.data
+ return (
+ -
+
+
+
+
+
+ Jouw vraag
+ ·
+ {USER_QUESTION_STATUS_LABEL[uq.status]}
+ ·
+
+
+
{uq.question}
+ {uq.status === 'pending' ? (
+
+ Claude denkt na…
+ ) : uq.answer ? (
+
+
+ Antwoord van Claude
+
+
+ {uq.answer}
+
+
) : null}
- >
- )}
-
-
- )
- })}
-
+
+
+ )
+ })}
+
+ )}
+
+ {showChatInput &&
}
+
)
}
diff --git a/components/ideas/user-chat-input.tsx b/components/ideas/user-chat-input.tsx
new file mode 100644
index 0000000..cbdb4bd
--- /dev/null
+++ b/components/ideas/user-chat-input.tsx
@@ -0,0 +1,74 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import { useRouter } from 'next/navigation'
+import { Send } from 'lucide-react'
+import { toast } from 'sonner'
+
+import { Button } from '@/components/ui/button'
+import { Textarea } from '@/components/ui/textarea'
+import { createUserQuestionAction } from '@/actions/user-questions'
+
+interface Props {
+ ideaId: string
+ isDemo?: boolean
+}
+
+export function UserChatInput({ ideaId, isDemo = false }: Props) {
+ const router = useRouter()
+ const [text, setText] = useState('')
+ const [pending, startTransition] = useTransition()
+
+ function submit() {
+ const trimmed = text.trim()
+ if (!trimmed) {
+ toast.error('Vraag mag niet leeg zijn')
+ return
+ }
+ startTransition(async () => {
+ const r = await createUserQuestionAction(ideaId, trimmed)
+ if ('error' in r) {
+ toast.error(r.error)
+ return
+ }
+ toast.success('Vraag verzonden — Claude gaat ermee aan de slag.')
+ setText('')
+ router.refresh()
+ })
+ }
+
+ if (isDemo) {
+ return (
+
+
+ Demo-modus: vragen stellen is niet beschikbaar.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx
index dac19e4..893e21a 100644
--- a/components/sprint/sprint-header.tsx
+++ b/components/sprint/sprint-header.tsx
@@ -61,7 +61,7 @@ function toDateInputValue(d: Date | null) {
return d.toISOString().slice(0, 10)
}
-export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
+export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
const [editingGoal, setEditingGoal] = useState(false)
const [editingDates, setEditingDates] = useState(false)
const [completeOpen, setCompleteOpen] = useState(false)
diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts
index 13947e2..bbb23c5 100644
--- a/lib/realtime/use-notifications-realtime.ts
+++ b/lib/realtime/use-notifications-realtime.ts
@@ -208,5 +208,5 @@ export function useNotificationsRealtime() {
document.removeEventListener('visibilitychange', onVisibilityChange)
close()
}
- }, [])
+ }, [router])
}