* feat(PBI-49): add debugProps helper + Vitest test
Adds lib/debug.ts with debugProps(id, component, file) that returns
data-debug-id and data-debug-label attrs in dev mode, empty object in
production. Adds __tests__/lib/debug.test.ts covering both modes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(PBI-49): add debug-id pattern doc + CLAUDE.md reference
Adds docs/patterns/debug-id.md documenting the named-component boundary
rule (6 punten), helper-voorbeeld, skip-criteria en motivatie voor
handmatige pad-argumenten. Voegt verwijzing toe aan CLAUDE.md
patterns-tabel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(PBI-49): migrate 17 shared/ components to debugProps helper
Replace hardcoded data-debug-id + data-debug-label attribute pairs with
{...debugProps(id, component, file)} spread in all 17 components/shared/
files. Existing debug-ids preserved unchanged.
* feat(PBI-49): add debugProps to backlog/, sprint/, solo/ components
* feat(PBI-49): add debugProps to jobs/ + ideas/ components
* feat(PBI-49): add debugProps to products/ + settings/ + notifications/ components
* feat(PBI-49): add debugProps to admin/ + dashboard/ + dialogs/ + mobile/ + split-pane/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(PBI-49): use attr(data-debug-id) for debug tooltip in globals.css
* refactor(PBI-49): remove data-debug-label from debugProps helper + test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(PBI-49): strip unused component/file args from debugProps in shared/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to StatusBar, NavBar, PanelNavBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to components/sprint/*
- new-sprint-dialog: __submit on submit button
- sprint-backlog: __list on SprintBacklogLeft + SprintBacklogRight scroll areas
- sprint-board-client: root wrapper div (display:contents) + __drag-overlay
- sprint-header: __title on goal button, __dates on dates button, __actions on action cluster
- sprint-run-controls: root on controls div, __start/__cancel on action buttons; __blockers-dialog on dialog content
- start-sprint-button: root on trigger button, __dialog on dialog content, __submit on submit button
- sync-active-sprint-cookie: no debug-id (returns null, side-effect only), comment added
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to components/backlog/*
* feat(PBI-49): add BEM sub-element data-debug-id to components/ideas/*
* feat(PBI-49): add BEM sub-element data-debug-id to components/dashboard/* + components/markdown.tsx
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to new-product-button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to components/solo/*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-elements to nav-status-indicators
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to components/jobs/*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to components/products/*
* feat(PBI-49): add BEM sub-element data-debug-id to components/notifications/*
- answer-modal: __content (scroll area), __submit (footer)
- notifications-bridge: skip comment (bridge, non-rendering wrapper)
- notifications-realtime-mount: skip comment (returns null)
- notifications-sheet: __header, __items (questions list)
- push-toggle: __switch (button), __label (button text) on subscribed/unsubscribed states
* feat(PBI-49): add BEM sub-element data-debug-id to components/settings/*
- leave-product-button: root only (single-button component)
- min-quota-editor: __input (number input), __save (save button)
- profile-editor: __username (bio/short-description input), __save (submit)
- role-manager: __roles (checkbox list), __add (save button)
- token-manager: __tokens (active tokens list), __generate (create button)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(PBI-49): add BEM sub-element data-debug-id to admin, auth, dialogs, entity-dialog, mobile, split-pane
* docs(PBI-49): add debug-labels BEM pattern doc + CLAUDE.md entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
'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 { debugProps } from '@/lib/debug'
|
|
import { updateIdeaAction, archiveIdeaAction, updateSecondaryProductsAction } from '@/actions/ideas'
|
|
import { IdeaRowActions } from '@/components/ideas/idea-row-actions'
|
|
import { IdeaMdEditor } from '@/components/ideas/idea-md-editor'
|
|
import { IdeaPbiLinkCard } from '@/components/ideas/idea-pbi-link-card'
|
|
import { IdeaTimeline } from '@/components/ideas/idea-timeline'
|
|
import { IdeaSyncTab } from '@/components/ideas/idea-sync-tab'
|
|
import { DownloadMdButton } from '@/components/ideas/download-md-button'
|
|
import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server'
|
|
|
|
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' | 'sync'
|
|
|
|
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
|
|
}
|
|
|
|
export interface IdeaUserQuestionDto {
|
|
id: string
|
|
question: string
|
|
answer: string | null
|
|
status: 'pending' | 'answered'
|
|
created_at: string
|
|
}
|
|
|
|
interface Props {
|
|
idea: IdeaDto
|
|
grill_md: string | null
|
|
plan_md: string | null
|
|
products: ProductOption[]
|
|
logs: IdeaLog[]
|
|
questions: IdeaQuestion[]
|
|
userQuestions: IdeaUserQuestionDto[]
|
|
isDemo: boolean
|
|
initialTab: string
|
|
syncData: IdeaSyncData | null
|
|
}
|
|
|
|
export function IdeaDetailLayout({
|
|
idea,
|
|
grill_md,
|
|
plan_md,
|
|
products,
|
|
logs,
|
|
questions,
|
|
userQuestions,
|
|
isDemo,
|
|
initialTab,
|
|
syncData,
|
|
}: Props) {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
const showSync = syncData !== null && idea.status === 'planned'
|
|
const TAB_KEYS: TabKey[] = showSync
|
|
? ['idee', 'grill', 'plan', 'timeline', 'sync']
|
|
: ['idee', 'grill', 'plan', 'timeline']
|
|
const tab = (TAB_KEYS.includes(initialTab as TabKey) ? 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" {...debugProps('idea-detail-layout', 'IdeaDetailLayout', 'components/ideas/idea-detail-layout.tsx')}>
|
|
{/* 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 ideas
|
|
</Link>
|
|
|
|
{/* Header */}
|
|
<header className="flex flex-wrap items-start justify-between gap-4" data-debug-id="idea-detail-layout__header">
|
|
<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>
|
|
{idea.secondary_products.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 mt-1">
|
|
{idea.secondary_products.map((sp) => (
|
|
<span
|
|
key={sp.id}
|
|
className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground"
|
|
>
|
|
{sp.product.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<IdeaRowActions idea={idea} isDemo={isDemo} onArchive={handleArchive} />
|
|
</header>
|
|
|
|
{/* PBI-link card / Re-link banner bij PLANNED */}
|
|
<IdeaPbiLinkCard idea={idea} isDemo={isDemo} />
|
|
|
|
{/* Tab-switcher */}
|
|
<nav className="border-b border-input flex gap-1" data-debug-id="idea-detail-layout__main">
|
|
{([
|
|
{ key: 'idee' as TabKey, label: 'Idee', disabled: false, hasContent: true },
|
|
{ key: 'grill' as TabKey, label: 'Grill', disabled: !grill_md, hasContent: !!grill_md },
|
|
{ key: 'plan' as TabKey, label: 'Plan', disabled: !plan_md, hasContent: !!plan_md },
|
|
{ key: 'timeline' as TabKey, label: 'Timeline', disabled: false, hasContent: true },
|
|
...(showSync
|
|
? [{ key: 'sync' as TabKey, label: 'Sync', disabled: false, hasContent: true }]
|
|
: []),
|
|
] as const).map((t) => (
|
|
<button
|
|
key={t.key}
|
|
type="button"
|
|
onClick={() => !t.disabled && setTab(t.key)}
|
|
disabled={t.disabled}
|
|
className={`px-4 py-2 text-sm border-b-2 transition-colors ${
|
|
t.disabled
|
|
? 'border-transparent text-muted-foreground/40 cursor-not-allowed'
|
|
: tab === t.key
|
|
? 'border-primary text-foreground'
|
|
: 'border-transparent text-muted-foreground hover:text-foreground'
|
|
}`}
|
|
>
|
|
{t.label}
|
|
{t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && (
|
|
<span className="ml-1 text-[10px] text-status-done">●</span>
|
|
)}
|
|
{t.key === 'timeline' &&
|
|
(logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? (
|
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
|
({logs.length + questions.length + userQuestions.length})
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Tab content */}
|
|
{tab === 'idee' && (
|
|
<IdeaFormSection
|
|
idea={idea}
|
|
products={products}
|
|
isDemo={isDemo}
|
|
pending={pending}
|
|
secondaryProducts={idea.secondary_products}
|
|
/>
|
|
)}
|
|
{tab === 'grill' && (
|
|
<MdSection
|
|
kind="grill"
|
|
markdown={grill_md}
|
|
// M12 grill-keuze 12: grill_md editable in GRILLED + PLAN_READY.
|
|
editable={
|
|
!isDemo && (idea.status === 'grilled' || idea.status === 'plan_ready')
|
|
}
|
|
ideaId={idea.id}
|
|
/>
|
|
)}
|
|
{tab === 'plan' && (
|
|
<MdSection
|
|
kind="plan"
|
|
markdown={plan_md}
|
|
// M12 grill-keuze 12: plan_md editable alleen in PLAN_READY.
|
|
editable={!isDemo && idea.status === 'plan_ready'}
|
|
ideaId={idea.id}
|
|
/>
|
|
)}
|
|
{tab === 'timeline' && (
|
|
<IdeaTimeline
|
|
logs={logs}
|
|
questions={questions}
|
|
userQuestions={userQuestions}
|
|
planMd={plan_md}
|
|
ideaId={idea.id}
|
|
isDemo={isDemo}
|
|
/>
|
|
)}
|
|
{tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Idee-tab: inline form (geen modal — de detailpagina IS de form).
|
|
|
|
interface FormProps {
|
|
idea: IdeaDto
|
|
products: ProductOption[]
|
|
isDemo: boolean
|
|
pending: boolean
|
|
secondaryProducts: IdeaDto['secondary_products']
|
|
}
|
|
|
|
function IdeaFormSection({ idea, products, isDemo, pending, secondaryProducts }: 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 [selectedSecondary, setSelectedSecondary] = useState<string[]>(
|
|
secondaryProducts.map((sp) => sp.product_id),
|
|
)
|
|
const [submitting, startSubmit] = useTransition()
|
|
|
|
const secondaryDirty =
|
|
JSON.stringify([...selectedSecondary].sort()) !==
|
|
JSON.stringify(secondaryProducts.map((sp) => sp.product_id).sort())
|
|
|
|
const dirty =
|
|
title !== idea.title ||
|
|
description !== (idea.description ?? '') ||
|
|
productId !== (idea.product_id ?? '') ||
|
|
secondaryDirty
|
|
|
|
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
|
|
}
|
|
if (secondaryDirty) {
|
|
const r2 = await updateSecondaryProductsAction(idea.id, selectedSecondary)
|
|
if ('error' in r2) {
|
|
toast.error(r2.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>
|
|
{products.filter((p) => p.id !== productId).length > 0 && (
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">Extra producten</label>
|
|
<div className="space-y-1">
|
|
{products
|
|
.filter((p) => p.id !== productId)
|
|
.map((p) => (
|
|
<label key={p.id} className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedSecondary.includes(p.id)}
|
|
onChange={(e) =>
|
|
setSelectedSecondary((prev) =>
|
|
e.target.checked ? [...prev, p.id] : prev.filter((id) => id !== p.id),
|
|
)
|
|
}
|
|
disabled={!editable || pending || submitting}
|
|
/>
|
|
{p.name}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</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 ?? '')
|
|
setSelectedSecondary(secondaryProducts.map((sp) => sp.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, editable, ideaId }: MdProps) {
|
|
const [editing, setEditing] = useState(false)
|
|
|
|
if (editing) {
|
|
return (
|
|
<IdeaMdEditor
|
|
ideaId={ideaId}
|
|
kind={kind}
|
|
initialValue={markdown ?? ''}
|
|
onCancel={() => setEditing(false)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (!markdown) {
|
|
return (
|
|
<div className="space-y-3 py-6">
|
|
<p className="text-sm text-muted-foreground 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>
|
|
{editable && (
|
|
<div className="flex justify-center">
|
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
|
Schrijf zelf
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-end gap-2">
|
|
<DownloadMdButton ideaId={ideaId} kind={kind} hasContent={markdown !== null} />
|
|
{editable && (
|
|
<Button size="sm" variant="outline" onClick={() => setEditing(true)}>
|
|
Bewerk
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<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>
|
|
</div>
|
|
)
|
|
}
|