From e816ccc77a17148543dac69323984d31253c4df0 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 15:53:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(PBI-33):=20chat-kanaal=20UI=20=E2=80=94=20?= =?UTF-8?q?IdeaTimeline=20merge=20+=20UserChatInput?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voltooit de UI-laag van PLAN_CHAT (gebruikersvragen over plan, Claude antwoordt async). Backend (UserQuestion model, createUserQuestionAction, SSE-handling, server-side prop-passing) was al aanwezig — alleen de UI-koppeling ontbrak waardoor userQuestions ongebruikt bleven. - IdeaDetailLayout geeft userQuestions/planMd/ideaId/isDemo door aan IdeaTimeline en telt user-questions mee in de tab-count - IdeaTimeline mergt user-questions chronologisch met logs+questions, rendert ze met MessageCircle-icoon en pending/answered status, en toont onderaan UserChatInput wanneer plan_md aanwezig is - UserChatInput nieuw component met textarea + verzend-knop dat createUserQuestionAction aanroept en op success router.refresh() triggert zodat SSE de pending-state oppikt - useNotificationsRealtime: router toegevoegd aan useEffect-deps zodat router.refresh() op user_question/idea-job events werkt zonder stale-closure waarschuwing Co-Authored-By: Claude Opus 4.7 (1M context) --- components/ideas/idea-detail-layout.tsx | 16 +- components/ideas/idea-timeline.tsx | 246 +++++++++++++-------- components/ideas/user-chat-input.tsx | 74 +++++++ lib/realtime/use-notifications-realtime.ts | 2 +- 4 files changed, 247 insertions(+), 91 deletions(-) create mode 100644 components/ideas/user-chat-input.tsx 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 ( -
    1. - - {LOG_ICON[type] ?? } - -
      -
      - - {LOG_LABEL[type] ?? type} + if (entry.kind === 'log') { + const type = entry.data.type as IdeaLogType + return ( +
    2. + + {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 ( -
  1. - - - -
    -
    - 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} +
    +
  2. + ) + } + + if (entry.kind === 'question') { + const q = entry.data + return ( +
  3. + + + +
    +
    + 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} + + )} +
    +
  4. + ) + } + + // user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT) + const uq = entry.data + return ( +
  5. + + + +
    +
    + Jouw vraag + · + {USER_QUESTION_STATUS_LABEL[uq.status]} + · + +
    +

    {uq.question}

    + {uq.status === 'pending' ? ( +

    + Claude denkt na…

    + ) : uq.answer ? ( +
    + + Antwoord van Claude + +

    + {uq.answer} +

    +
    ) : null} - - )} -
    -
  6. - ) - })} -
+ + + ) + })} + + )} + + {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 ( +
+ +