From 1362996a2b7991558630b1e8c60eb53ce9f28272 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 21:35:48 +0200 Subject: [PATCH] ui: /ideas/[id] detail page with 4-tab layout (M12 T-510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app/(app)/ideas/[id]/page.tsx (server-component): - user_id-only fetch with notFound() on miss (anti-enumeration) - Parallel fetch: idea+product+pbi, products list, recent logs (100), questions (50) components/ideas/idea-detail-layout.tsx (client-component): - Header: code + title + status-badge + product-link + IdeaRowActions - PBI-link card when PLANNED (or Re-link banner when pbi removed — T-512 wires the action) - URL-based tab switcher (?tab=idee|grill|plan|timeline) — bookmarkable - Idee-tab: inline edit form with isIdeaEditable guard, dirty-tracking + Reset/Save buttons - Grill/Plan-tabs: read-only md preview (T-511 will add the editor) - Timeline-tab: chronological merge of IdeaLog + ClaudeQuestion entries (T-512 will polish the styling and component-split) Tests: 546/546 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/ideas/[id]/page.tsx | 98 ++++++ components/ideas/idea-detail-layout.tsx | 426 ++++++++++++++++++++++++ 2 files changed, 524 insertions(+) create mode 100644 app/(app)/ideas/[id]/page.tsx create mode 100644 components/ideas/idea-detail-layout.tsx diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx new file mode 100644 index 0000000..0d25fb7 --- /dev/null +++ b/app/(app)/ideas/[id]/page.tsx @@ -0,0 +1,98 @@ +import { cookies } from 'next/headers' +import { notFound } from 'next/navigation' +import { getIronSession } from 'iron-session' + +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { ideaToDto } from '@/lib/idea-dto' +import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout' + +export const dynamic = 'force-dynamic' + +interface PageProps { + params: Promise<{ id: string }> + searchParams: Promise<{ tab?: string }> +} + +export default async function IdeaDetailPage({ params, searchParams }: PageProps) { + const session = await getIronSession(await cookies(), sessionOptions) + if (!session.userId) notFound() // proxy.ts redirect zou ons al moeten hebben + + const { id } = await params + const { tab } = await searchParams + + // M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum). + const idea = await prisma.idea.findFirst({ + where: { id, user_id: session.userId }, + include: { + product: { select: { id: true, name: true, repo_url: true } }, + pbi: { select: { id: true, code: true, title: true } }, + }, + }) + if (!idea) notFound() + + // Producten voor de "koppel product"-dropdown in de form-tab. + const products = await prisma.product.findMany({ + where: { ...productAccessFilter(session.userId), archived: false }, + orderBy: { name: 'asc' }, + select: { id: true, name: true, repo_url: true }, + }) + + // Recent logs (laatste 100) voor de Timeline-tab. + const logs = await prisma.ideaLog.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + type: true, + content: true, + metadata: true, + created_at: true, + }, + }) + + // Open vragen voor dit idee — voor de Timeline-tab. + const questions = await prisma.claudeQuestion.findMany({ + where: { idea_id: id }, + orderBy: { created_at: 'desc' }, + take: 50, + select: { + id: true, + question: true, + options: true, + status: true, + answer: true, + created_at: true, + expires_at: true, + }, + }) + + return ( + ({ + id: l.id, + type: l.type, + content: l.content, + metadata: l.metadata, + created_at: l.created_at.toISOString(), + }))} + questions={questions.map((q) => ({ + id: q.id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + status: q.status as 'open' | 'answered' | 'cancelled' | 'expired', + answer: q.answer ?? null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + }))} + isDemo={session.isDemo ?? false} + initialTab={tab ?? 'idee'} + /> + ) +} diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx new file mode 100644 index 0000000..7257314 --- /dev/null +++ b/components/ideas/idea-detail-layout.tsx @@ -0,0 +1,426 @@ +'use client' + +// IdeaDetailLayout — top-level container voor /ideas/[id]. +// Bevat: header (titel + status-badge + row-actions), tab-switcher +// (Idee/Grill/Plan/Timeline), en per-tab content. +// +// URL-based tabs (?tab=grill) — bookmarkable + refresh-safe. +// Md-editor (T-511), timeline (T-512), pbi-link-card (T-512) komen later. + +import { useState, useTransition } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, ExternalLink } from 'lucide-react' +import { toast } from 'sonner' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { getIdeaStatusBadge } from '@/lib/idea-status-colors' +import type { IdeaStatusApi } from '@/lib/idea-status' +import { isIdeaEditable } from '@/lib/idea-status' +import type { IdeaDto } from '@/lib/idea-dto' +import { updateIdeaAction, archiveIdeaAction } from '@/actions/ideas' +import { IdeaRowActions } from '@/components/ideas/idea-row-actions' + +const API_TO_DB: Record[0]> = { + draft: 'DRAFT', + grilling: 'GRILLING', + grill_failed: 'GRILL_FAILED', + grilled: 'GRILLED', + planning: 'PLANNING', + plan_failed: 'PLAN_FAILED', + plan_ready: 'PLAN_READY', + planned: 'PLANNED', +} + +type TabKey = 'idee' | 'grill' | 'plan' | 'timeline' + +const TABS: { key: TabKey; label: string }[] = [ + { key: 'idee', label: 'Idee' }, + { key: 'grill', label: 'Grill' }, + { key: 'plan', label: 'Plan' }, + { key: 'timeline', label: 'Timeline' }, +] + +interface IdeaLog { + id: string + type: string + content: string + metadata: unknown + created_at: string +} + +interface IdeaQuestion { + id: string + question: string + options: string[] | null + status: 'open' | 'answered' | 'cancelled' | 'expired' + answer: string | null + created_at: string + expires_at: string +} + +interface ProductOption { + id: string + name: string + repo_url: string | null +} + +interface Props { + idea: IdeaDto + grill_md: string | null + plan_md: string | null + products: ProductOption[] + logs: IdeaLog[] + questions: IdeaQuestion[] + isDemo: boolean + initialTab: string +} + +export function IdeaDetailLayout({ + idea, + grill_md, + plan_md, + products, + logs, + questions, + isDemo, + initialTab, +}: Props) { + const router = useRouter() + const searchParams = useSearchParams() + const [pending, startTransition] = useTransition() + + const tab = (TABS.some((t) => t.key === initialTab) ? initialTab : 'idee') as TabKey + + function setTab(key: TabKey) { + const params = new URLSearchParams(searchParams.toString()) + params.set('tab', key) + router.replace(`/ideas/${idea.id}?${params.toString()}`, { scroll: false }) + } + + function handleArchive() { + if (isDemo) return + if (!confirm('Idee archiveren?')) return + startTransition(async () => { + const r = await archiveIdeaAction(idea.id) + if ('error' in r) { + toast.error(r.error) + return + } + toast.success('Idee gearchiveerd') + router.push('/ideas') + }) + } + + const badge = getIdeaStatusBadge(API_TO_DB[idea.status]) + + return ( +
+ {/* Breadcrumb / back-link */} + + + Alle ideeën + + + {/* Header */} +
+
+

{idea.code}

+

{idea.title}

+
+ + {badge.label} + + {idea.product ? ( + + {idea.product.name} + + + ) : ( + geen product + )} +
+
+ +
+ + {/* PBI-link card bij PLANNED — placeholder voor T-512 */} + {idea.status === 'planned' && idea.pbi && ( +
+

+ Gematerialiseerd als{' '} + + {idea.pbi.code} — {idea.pbi.title} + +

+
+ )} + {idea.status === 'planned' && !idea.pbi && ( +
+

+ De gekoppelde PBI bestaat niet meer. Klik om dit idee terug naar + PLAN_READY te zetten en opnieuw te materialiseren. +

+
+ )} + + {/* Tab-switcher */} + + + {/* Tab content */} + {tab === 'idee' && ( + + )} + {tab === 'grill' && ( + + )} + {tab === 'plan' && ( + + )} + {tab === 'timeline' && } +
+ ) +} + +// --------------------------------------------------------------------------- +// Idee-tab: inline form (geen modal — de detailpagina IS de form). + +interface FormProps { + idea: IdeaDto + products: ProductOption[] + isDemo: boolean + pending: boolean +} + +function IdeaFormSection({ idea, products, isDemo, pending }: FormProps) { + const router = useRouter() + const editable = + !isDemo && + isIdeaEditable(API_TO_DB[idea.status]) + const [title, setTitle] = useState(idea.title) + const [description, setDescription] = useState(idea.description ?? '') + const [productId, setProductId] = useState(idea.product_id ?? '') + const [submitting, startSubmit] = useTransition() + + const dirty = + title !== idea.title || + description !== (idea.description ?? '') || + productId !== (idea.product_id ?? '') + + function save() { + startSubmit(async () => { + const r = await updateIdeaAction(idea.id, { + title, + description: description || null, + product_id: productId || null, + }) + if ('error' in r) { + toast.error(r.error) + return + } + toast.success('Opgeslagen') + router.refresh() + }) + } + + return ( +
+
+ + setTitle(e.target.value)} + disabled={!editable || pending || submitting} + /> +
+
+ +