344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
'use client'
|
|
|
|
// 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 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'
|
|
import {
|
|
ClipboardList,
|
|
FileText,
|
|
HelpCircle,
|
|
Lightbulb,
|
|
MessageCircle,
|
|
RefreshCw,
|
|
StickyNote,
|
|
Wrench,
|
|
} from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { debugProps } from '@/lib/debug'
|
|
import { answerQuestion } from '@/actions/questions'
|
|
import { UserChatInput } from '@/components/ideas/user-chat-input'
|
|
|
|
import type { IdeaLogType } from '@prisma/client'
|
|
|
|
export interface TimelineLog {
|
|
id: string
|
|
type: string
|
|
content: string
|
|
metadata: unknown
|
|
created_at: string
|
|
}
|
|
|
|
export interface TimelineQuestion {
|
|
id: string
|
|
question: string
|
|
options: string[] | null
|
|
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
|
answer: string | null
|
|
created_at: string
|
|
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> = {
|
|
DECISION: <Lightbulb className="size-4" />,
|
|
NOTE: <StickyNote className="size-4" />,
|
|
GRILL_RESULT: <FileText className="size-4" />,
|
|
PLAN_RESULT: <ClipboardList className="size-4" />,
|
|
STATUS_CHANGE: <RefreshCw className="size-4" />,
|
|
JOB_EVENT: <Wrench className="size-4" />,
|
|
}
|
|
|
|
const LOG_LABEL: Record<IdeaLogType, string> = {
|
|
DECISION: 'Beslissing',
|
|
NOTE: 'Notitie',
|
|
GRILL_RESULT: 'Grill-resultaat',
|
|
PLAN_RESULT: 'Plan-resultaat',
|
|
STATUS_CHANGE: 'Status',
|
|
JOB_EVENT: 'Job-event',
|
|
}
|
|
|
|
const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
|
|
open: 'Open',
|
|
answered: 'Beantwoord',
|
|
cancelled: 'Geannuleerd',
|
|
expired: 'Verlopen',
|
|
}
|
|
|
|
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,
|
|
created_at: l.created_at,
|
|
data: l,
|
|
})),
|
|
...questions.map((q) => ({
|
|
kind: 'question' as const,
|
|
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))
|
|
|
|
const showChatInput = planMd !== null
|
|
|
|
return (
|
|
<div className="space-y-4" {...debugProps('idea-timeline', 'IdeaTimeline', 'components/ideas/idea-timeline.tsx')}>
|
|
{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}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
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>
|
|
)}
|
|
|
|
{showChatInput && <UserChatInput ideaId={ideaId} isDemo={isDemo} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// AnswerForm — inline antwoord op een open Claude-vraag.
|
|
// Voor multi-choice (options): knoppen die direct submitten met de gekozen
|
|
// optie. Anders: textarea + Submit knop.
|
|
|
|
function AnswerForm({
|
|
questionId,
|
|
options,
|
|
}: {
|
|
questionId: string
|
|
options: string[] | null
|
|
}) {
|
|
const router = useRouter()
|
|
const [text, setText] = useState('')
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
function submit(answer: string) {
|
|
const trimmed = answer.trim()
|
|
if (!trimmed) {
|
|
toast.error('Antwoord mag niet leeg zijn')
|
|
return
|
|
}
|
|
startTransition(async () => {
|
|
const r = await answerQuestion(questionId, trimmed)
|
|
if (!r.ok) {
|
|
toast.error(r.error)
|
|
return
|
|
}
|
|
toast.success('Antwoord verzonden — agent gaat door.')
|
|
setText('')
|
|
router.refresh()
|
|
})
|
|
}
|
|
|
|
if (options && options.length > 0) {
|
|
return (
|
|
<div className="flex flex-col gap-1.5 pt-1">
|
|
{options.map((opt, i) => (
|
|
<Button
|
|
key={i}
|
|
size="sm"
|
|
variant="outline"
|
|
className="justify-start text-left h-auto py-2 whitespace-normal"
|
|
disabled={pending}
|
|
onClick={() => submit(opt)}
|
|
>
|
|
{opt}
|
|
</Button>
|
|
))}
|
|
<details className="text-xs text-muted-foreground pt-1">
|
|
<summary className="cursor-pointer">Of typ een eigen antwoord</summary>
|
|
<div className="space-y-2 pt-2">
|
|
<Textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
rows={3}
|
|
placeholder="Eigen antwoord…"
|
|
disabled={pending}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}>
|
|
Verzend
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2 pt-1">
|
|
<Textarea
|
|
value={text}
|
|
onChange={(e) => setText(e.target.value)}
|
|
rows={3}
|
|
placeholder="Antwoord…"
|
|
disabled={pending}
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}>
|
|
Verzend
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|