feat(ST-0vtnydpi): Chat & Timeline tab — userQuestion rendering + UserChatInput

- IdeaTimeline: merge user_question entries into timeline (MessageCircle icon,
  pending/answered states); show UserChatInput below ol when planMd present
- UserChatInput: Textarea + submit button calling createUserQuestionAction,
  router.refresh() on success, sonner toast for errors
- IdeaDetailLayout: rename tab label to 'Chat & Timeline'; pass userQuestions,
  planMd, ideaId props to IdeaTimeline; export IdeaUserQuestionDto interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-05 17:46:04 +02:00
parent 99ae2d7e8f
commit 82736fd051
2 changed files with 112 additions and 4 deletions

View file

@ -44,7 +44,7 @@ const TABS: { key: TabKey; label: string }[] = [
{ key: 'idee', label: 'Idee' }, { key: 'idee', label: 'Idee' },
{ key: 'grill', label: 'Grill' }, { key: 'grill', label: 'Grill' },
{ key: 'plan', label: 'Plan' }, { key: 'plan', label: 'Plan' },
{ key: 'timeline', label: 'Timeline' }, { key: 'timeline', label: 'Chat & Timeline' },
] ]
interface IdeaLog { interface IdeaLog {
@ -221,7 +221,15 @@ export function IdeaDetailLayout({
ideaId={idea.id} 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}
/>
)}
</div> </div>
) )
} }

View file

@ -15,6 +15,7 @@ import {
FileText, FileText,
HelpCircle, HelpCircle,
Lightbulb, Lightbulb,
MessageCircle,
RefreshCw, RefreshCw,
StickyNote, StickyNote,
Wrench, Wrench,
@ -24,6 +25,7 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { answerQuestion } from '@/actions/questions' import { answerQuestion } from '@/actions/questions'
import { createUserQuestionAction } from '@/actions/user-questions'
import type { IdeaLogType } from '@prisma/client' import type { IdeaLogType } from '@prisma/client'
@ -45,9 +47,20 @@ export interface TimelineQuestion {
expires_at: string expires_at: string
} }
export interface TimelineUserQuestion {
id: string
question: string
answer: string | null
status: 'pending' | 'answered'
created_at: string
}
interface Props { interface Props {
logs: TimelineLog[] logs: TimelineLog[]
questions: TimelineQuestion[] questions: TimelineQuestion[]
userQuestions: TimelineUserQuestion[]
planMd: string | null
ideaId: string
} }
const LOG_ICON: Record<IdeaLogType, React.ReactNode> = { const LOG_ICON: Record<IdeaLogType, React.ReactNode> = {
@ -75,7 +88,7 @@ const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
expired: 'Verlopen', expired: 'Verlopen',
} }
export function IdeaTimeline({ logs, questions }: Props) { export function IdeaTimeline({ logs, questions, userQuestions, planMd, ideaId }: Props) {
const merged = [ const merged = [
...logs.map((l) => ({ ...logs.map((l) => ({
kind: 'log' as const, kind: 'log' as const,
@ -87,9 +100,14 @@ export function IdeaTimeline({ logs, questions }: Props) {
created_at: q.created_at, created_at: q.created_at,
data: q, 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)) ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
if (merged.length === 0) { if (merged.length === 0 && !planMd) {
return ( return (
<p className="text-sm text-muted-foreground py-8 text-center italic"> <p className="text-sm text-muted-foreground py-8 text-center italic">
Nog geen activiteit op dit idee. Nog geen activiteit op dit idee.
@ -98,6 +116,7 @@ export function IdeaTimeline({ logs, questions }: Props) {
} }
return ( return (
<div>
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2"> <ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
{merged.map((entry, i) => { {merged.map((entry, i) => {
// Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen // Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen
@ -138,6 +157,37 @@ export function IdeaTimeline({ logs, questions }: Props) {
) )
} }
if (entry.kind === 'user_question') {
const uq = entry.data
return (
<li key={`uq-${uq.id}-${i}`} 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>
<time>{time}</time>
</div>
<p className="text-sm">{uq.question}</p>
{uq.status === 'pending' ? (
<p className="text-xs text-muted-foreground italic">
Wacht op antwoord van Claude...
</p>
) : uq.answer ? (
<div className="mt-2 rounded bg-muted/50 p-3 text-sm">
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
Claude
</span>
{uq.answer}
</div>
) : null}
</div>
</li>
)
}
const q = entry.data const q = entry.data
return ( return (
<li key={`q-${q.id}-${i}`} className="relative"> <li key={`q-${q.id}-${i}`} className="relative">
@ -179,6 +229,12 @@ export function IdeaTimeline({ logs, questions }: Props) {
) )
})} })}
</ol> </ol>
{planMd && (
<div className="mt-4 border-t pt-4">
<UserChatInput ideaId={ideaId} />
</div>
)}
</div>
) )
} }
@ -269,3 +325,47 @@ function AnswerForm({
</div> </div>
) )
} }
// ---------------------------------------------------------------------------
// UserChatInput — stel een vraag aan Claude over het plan van dit idee.
function UserChatInput({ ideaId }: { ideaId: string }) {
const router = useRouter()
const [question, setQuestion] = useState('')
const [isPending, startTransition] = useTransition()
function submit() {
const trimmed = question.trim()
if (!trimmed) return
startTransition(async () => {
const res = await createUserQuestionAction(ideaId, trimmed)
if ('success' in res && res.success) {
setQuestion('')
toast.success('Vraag verstuurd — Claude antwoordt zodra mogelijk.')
router.refresh()
} else if ('error' in res) {
toast.error(res.error)
}
})
}
return (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Stel een vraag aan Claude
</p>
<Textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
rows={3}
placeholder="Vraag iets over het plan…"
disabled={isPending}
/>
<div className="flex justify-end">
<Button size="sm" disabled={isPending || !question.trim()} onClick={submit}>
Vraag stellen
</Button>
</div>
</div>
)
}