ui: /ideas/[id] detail page with 4-tab layout (M12 T-510)
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) <noreply@anthropic.com>
This commit is contained in:
parent
a1d3a83af5
commit
1362996a2b
2 changed files with 524 additions and 0 deletions
426
components/ideas/idea-detail-layout.tsx
Normal file
426
components/ideas/idea-detail-layout.tsx
Normal file
|
|
@ -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<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[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 (
|
||||
<div className="p-6 max-w-5xl mx-auto w-full space-y-6">
|
||||
{/* Breadcrumb / back-link */}
|
||||
<Link
|
||||
href="/ideas"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Alle ideeën
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="font-mono text-xs text-muted-foreground">{idea.code}</p>
|
||||
<h1 className="text-2xl font-medium text-foreground">{idea.title}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={badge.classes + (badge.pulse ? ' animate-pulse' : '')}>
|
||||
{badge.label}
|
||||
</span>
|
||||
{idea.product ? (
|
||||
<Link
|
||||
href={`/products/${idea.product.id}`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
{idea.product.name}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm italic text-muted-foreground">geen product</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<IdeaRowActions idea={idea} isDemo={isDemo} onArchive={handleArchive} />
|
||||
</header>
|
||||
|
||||
{/* PBI-link card bij PLANNED — placeholder voor T-512 */}
|
||||
{idea.status === 'planned' && idea.pbi && (
|
||||
<div className="rounded-md border border-status-done/30 bg-status-done/10 p-4">
|
||||
<p className="text-sm">
|
||||
Gematerialiseerd als{' '}
|
||||
<Link
|
||||
href={
|
||||
idea.product_id
|
||||
? `/products/${idea.product_id}/backlog#pbi-${idea.pbi.code}`
|
||||
: '#'
|
||||
}
|
||||
className="font-medium text-status-done hover:underline"
|
||||
>
|
||||
{idea.pbi.code} — {idea.pbi.title}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{idea.status === 'planned' && !idea.pbi && (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 space-y-2">
|
||||
<p className="text-sm">
|
||||
De gekoppelde PBI bestaat niet meer. Klik om dit idee terug naar
|
||||
<strong> PLAN_READY </strong>te zetten en opnieuw te materialiseren.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab-switcher */}
|
||||
<nav className="border-b border-input flex gap-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 text-sm border-b-2 transition-colors ${
|
||||
tab === t.key
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">
|
||||
({logs.length + questions.length})
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === 'idee' && (
|
||||
<IdeaFormSection
|
||||
idea={idea}
|
||||
products={products}
|
||||
isDemo={isDemo}
|
||||
pending={pending}
|
||||
/>
|
||||
)}
|
||||
{tab === 'grill' && (
|
||||
<MdSection
|
||||
kind="grill"
|
||||
markdown={grill_md}
|
||||
editable={false /* T-511 enables this in GRILLED|PLAN_READY */}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' && (
|
||||
<MdSection
|
||||
kind="plan"
|
||||
markdown={plan_md}
|
||||
editable={false /* T-511 enables in PLAN_READY */}
|
||||
ideaId={idea.id}
|
||||
/>
|
||||
)}
|
||||
{tab === 'timeline' && <TimelinePlaceholder logs={logs} questions={questions} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Titel</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={!editable || pending || submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Beschrijving</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={5}
|
||||
disabled={!editable || pending || submitting}
|
||||
placeholder="Korte beschrijving — wordt door Grill Me als startpunt gebruikt."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">Product</label>
|
||||
<select
|
||||
value={productId}
|
||||
onChange={(e) => setProductId(e.target.value)}
|
||||
disabled={!editable || pending || submitting}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Geen product</option>
|
||||
{products.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
{p.repo_url ? '' : ' (geen repo — vereist voor Grill/Make Plan)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{!editable && (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Idee is niet bewerkbaar in status {idea.status.toUpperCase()}.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!dirty || submitting}
|
||||
onClick={() => {
|
||||
setTitle(idea.title)
|
||||
setDescription(idea.description ?? '')
|
||||
setProductId(idea.product_id ?? '')
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button size="sm" disabled={!dirty || submitting} onClick={save}>
|
||||
Opslaan
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grill / Plan tab — read-only render. T-511 voegt edit-mode toe.
|
||||
|
||||
interface MdProps {
|
||||
kind: 'grill' | 'plan'
|
||||
markdown: string | null
|
||||
editable: boolean
|
||||
ideaId: string
|
||||
}
|
||||
|
||||
function MdSection({ kind, markdown }: MdProps) {
|
||||
if (!markdown) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center italic">
|
||||
{kind === 'grill'
|
||||
? 'Nog geen grill-resultaat. Klik "Grill" in de header om te starten.'
|
||||
: 'Nog geen plan. Voltooi eerst de grill-fase en klik dan "Plan".'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<pre className="rounded-md border border-input bg-surface-container p-4 text-sm whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto">
|
||||
{markdown}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline-placeholder. T-512 vervangt dit met de echte UNION-view.
|
||||
|
||||
interface TimelineProps {
|
||||
logs: IdeaLog[]
|
||||
questions: IdeaQuestion[]
|
||||
}
|
||||
|
||||
function TimelinePlaceholder({ logs, questions }: TimelineProps) {
|
||||
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,
|
||||
})),
|
||||
].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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{merged.map((entry, i) => (
|
||||
<li
|
||||
key={`${entry.kind}-${i}`}
|
||||
className="rounded-md border border-input bg-surface-container p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono uppercase">
|
||||
{entry.kind === 'log' ? entry.data.type : `vraag · ${entry.data.status}`}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<time>{new Date(entry.created_at).toLocaleString()}</time>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">
|
||||
{entry.kind === 'log' ? entry.data.content : entry.data.question}
|
||||
</p>
|
||||
{entry.kind === 'question' && entry.data.answer && (
|
||||
<p className="mt-1 text-sm text-muted-foreground border-l-2 border-primary pl-2">
|
||||
{entry.data.answer}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue