Merge branch 'main' into claude/nostalgic-knuth-a87bef
This commit is contained in:
commit
4774077051
10 changed files with 358 additions and 100 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold mb-4">Claude Jobs</h1>
|
||||
<JobsTable jobs={jobs} />
|
||||
<JobsTable jobs={jobsWithCost} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
|
|
@ -92,7 +94,7 @@ function JobRow({ job }: { job: Job }) {
|
|||
)
|
||||
}
|
||||
|
||||
export function JobsTable({ jobs }: { jobs: Job[] }) {
|
||||
function StatusTable({ jobs }: { jobs: Job[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -123,3 +125,86 @@ export function JobsTable({ jobs }: { jobs: Job[] }) {
|
|||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}</TableCell>
|
||||
<TableCell className="text-sm">{job.user.username}</TableCell>
|
||||
<TableCell className="text-sm">{job.product.name}</TableCell>
|
||||
<TableCell className="text-xs">{KIND_LABEL[job.kind] ?? job.kind}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{job.model_id ?? '—'}</TableCell>
|
||||
<TableCell className="text-xs font-mono">{costLabel}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2 justify-end">
|
||||
{ACTIVE_STATUSES.has(job.status) && (
|
||||
<Button variant="outline" size="sm" onClick={handleCancel} disabled={pending}>Annuleer</Button>
|
||||
)}
|
||||
<Button variant="destructive" size="sm" onClick={handleDelete} disabled={pending}>Verwijder</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
function CostsTable({ jobs }: { jobs: Job[] }) {
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Gebruiker</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Kosten (USD)</TableHead>
|
||||
<TableHead>Aangemaakt</TableHead>
|
||||
<TableHead className="text-right">Acties</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
|
||||
Geen jobs gevonden
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{jobs.map((job) => <CostRow key={job.id} job={job} />)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export function JobsTable({ jobs }: { jobs: Job[] }) {
|
||||
const [view, setView] = useState<'status' | 'costs'>('status')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === 'status' ? 'default' : 'outline'}
|
||||
onClick={() => setView('status')}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={view === 'costs' ? 'default' : 'outline'}
|
||||
onClick={() => setView('costs')}
|
||||
>
|
||||
Kosten
|
||||
</Button>
|
||||
</div>
|
||||
{view === 'status' ? <StatusTable jobs={jobs} /> : <CostsTable jobs={jobs} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -210,9 +210,10 @@ export function IdeaDetailLayout({
|
|||
{t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && (
|
||||
<span className="ml-1 text-[10px] text-status-done">●</span>
|
||||
)}
|
||||
{t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? (
|
||||
{t.key === 'timeline' &&
|
||||
(logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({logs.length + questions.length})
|
||||
({logs.length + questions.length + userQuestions.length})
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
|
@ -249,7 +250,16 @@ export function IdeaDetailLayout({
|
|||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
{tab === 'timeline' && <IdeaTimeline logs={logs} questions={questions} />}
|
||||
{tab === 'timeline' && (
|
||||
<IdeaTimeline
|
||||
logs={logs}
|
||||
questions={questions}
|
||||
userQuestions={userQuestions}
|
||||
planMd={plan_md}
|
||||
ideaId={idea.id}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
)}
|
||||
{tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<IdeaLogType, React.ReactNode> = {
|
||||
|
|
@ -75,7 +90,19 @@ const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
|
|||
expired: 'Verlopen',
|
||||
}
|
||||
|
||||
export function IdeaTimeline({ logs, questions }: Props) {
|
||||
const USER_QUESTION_STATUS_LABEL: Record<TimelineUserQuestion['status'], string> = {
|
||||
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 (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center italic">
|
||||
Nog geen activiteit op dit idee.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const showChatInput = planMd !== null
|
||||
|
||||
return (
|
||||
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
|
||||
{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',
|
||||
})
|
||||
<div className="space-y-4">
|
||||
{merged.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center italic">
|
||||
Nog geen activiteit op dit idee.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
|
||||
{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 (
|
||||
<li key={`l-${entry.data.id}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
|
||||
{LOG_ICON[type] ?? <StickyNote className="size-4" />}
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
{LOG_LABEL[type] ?? type}
|
||||
if (entry.kind === 'log') {
|
||||
const type = entry.data.type as IdeaLogType
|
||||
return (
|
||||
<li key={`l-${entry.data.id}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
|
||||
{LOG_ICON[type] ?? <StickyNote className="size-4" />}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
|
||||
{entry.data.metadata != null &&
|
||||
typeof entry.data.metadata === 'object' &&
|
||||
Object.keys(entry.data.metadata as object).length > 0 ? (
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">metadata</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
|
||||
{JSON.stringify(entry.data.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const q = entry.data
|
||||
return (
|
||||
<li key={`q-${q.id}-${i}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
|
||||
<HelpCircle className="size-4" />
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Vraag</span>
|
||||
<span>·</span>
|
||||
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm">{q.question}</p>
|
||||
{q.status === 'open' ? (
|
||||
<AnswerForm questionId={q.id} options={q.options} />
|
||||
) : (
|
||||
<>
|
||||
{q.options && q.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{q.options.map((o, ii) => (
|
||||
<li key={ii}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{q.answer ? (
|
||||
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
|
||||
Antwoord
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
{LOG_LABEL[type] ?? type}
|
||||
</span>
|
||||
{q.answer}
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
|
||||
{entry.data.metadata != null &&
|
||||
typeof entry.data.metadata === 'object' &&
|
||||
Object.keys(entry.data.metadata as object).length > 0 ? (
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">metadata</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
|
||||
{JSON.stringify(entry.data.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (entry.kind === 'question') {
|
||||
const q = entry.data
|
||||
return (
|
||||
<li key={`q-${q.id}-${i}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
|
||||
<HelpCircle className="size-4" />
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Vraag</span>
|
||||
<span>·</span>
|
||||
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm">{q.question}</p>
|
||||
{q.status === 'open' ? (
|
||||
<AnswerForm questionId={q.id} options={q.options} />
|
||||
) : (
|
||||
<>
|
||||
{q.options && q.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{q.options.map((o, ii) => (
|
||||
<li key={ii}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{q.answer ? (
|
||||
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
|
||||
Antwoord
|
||||
</span>
|
||||
{q.answer}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT)
|
||||
const uq = entry.data
|
||||
return (
|
||||
<li key={`uq-${uq.id}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-primary">
|
||||
<MessageCircle className="size-4" />
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Jouw vraag</span>
|
||||
<span>·</span>
|
||||
<span>{USER_QUESTION_STATUS_LABEL[uq.status]}</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{uq.question}</p>
|
||||
{uq.status === 'pending' ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Claude denkt na…
|
||||
</p>
|
||||
) : uq.answer ? (
|
||||
<div className="border-l-2 border-primary pl-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-primary">
|
||||
Antwoord van Claude
|
||||
</span>
|
||||
<p className="text-sm whitespace-pre-wrap text-foreground mt-1">
|
||||
{uq.answer}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{showChatInput && <UserChatInput ideaId={ideaId} isDemo={isDemo} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
74
components/ideas/user-chat-input.tsx
Normal file
74
components/ideas/user-chat-input.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-md border border-input bg-surface-container p-3">
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Demo-modus: vragen stellen is niet beschikbaar.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-input bg-surface-container p-3">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Stel een vraag over dit plan
|
||||
</label>
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Bijv. Waarom is gekozen voor X in plaats van Y?"
|
||||
disabled={pending}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={pending || !text.trim()}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{pending ? 'Bezig…' : 'Verzend'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -208,5 +208,5 @@ export function useNotificationsRealtime() {
|
|||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
close()
|
||||
}
|
||||
}, [])
|
||||
}, [router])
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue