Scrum4Me/components/ideas/idea-detail-layout.tsx
Janpeter Visser d84cdf664f
feat(PBI-67): IDEA_REVIEW_PLAN — iterative multi-model plan review (#199)
* feat(ideas): upload-plan knop — short-circuit van Make-Plan AI-flow

Voegt een 'Upload plan' knop toe in idea-row-actions (verschijnt in zowel
list als idea-detail). Klik → file picker → kies .md → server-side parse +
opslaan; idea-status springt naar PLAN_READY. Vandaaruit de bestaande
'Maak PBI' knop voor materialize.

Server (uploadPlanMdAction):
- Toegestaan vanuit DRAFT, GRILLED, PLAN_FAILED, PLAN_READY
- DRAFT → skip-grill: status gaat direct naar PLAN_READY
- PLAN_READY overschrijft het bestaande plan (consistent met
  updatePlanMdAction, geen confirmation)
- Geblokkeerd in GRILLING/PLANNING (job loopt), PLANNED (al gematerialiseerd)
- Parse-failure → 422 + details (NIET opslaan, zodat een onparseerbaar plan
  nooit in de DB belandt)
- Empty / >100k chars → 422
- Schrijft IdeaLog NOTE met from_status + length
- Rate-limit + demo-guard + ownership-check via loadOwnedIdea (zelfde
  patroon als updatePlanMdAction)

UI (idea-row-actions.tsx):
- Hidden <input type=file accept=".md,.markdown,text/markdown,text/plain">
- FileReader → text → action
- Toast bij success + router.refresh()
- Blocked-tooltip in andere statussen

Tests: 10 nieuwe in __tests__/actions/ideas-crud.test.ts dekkend voor:
happy paths (DRAFT/GRILLED/PLAN_READY-overwrite/PLAN_FAILED), blocks
(PLANNED/GRILLING), validation (empty/oversized/parse-fail), 404.
Full suite groen: 849/849.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add reviews for Bootstrap-wizard plans v3.2 to v3.4

- Review v3.2: Addressed executor model, fire-and-forget issues, and PAT handling.
- Review v3.3: Improved transaction handling, stale recovery, and ID generation.
- Review v3.4: Finalized GitHub permissions, catalog versioning, and E2E verification queries.
- Updated recommendations for each version to enhance implementation readiness.

* docs(plans): M8 bootstrap-wizard upload-variant v1.4 — backtick-paden

Upload-variant van het volledige technische plan (docs/plans/M8-bootstrap-wizard.md),
bedoeld voor de "Upload plan"-functie. Genereert 1 PBI + 4 Stories + 22 Tasks
via materializeIdeaPlanAction.

v1.4-aanpassingen tov eerdere generatie-iteratie:
- Alle bestandspaden in implementation_plan in backticks (path-extractor matchen)
- Expliciete "Bestanden:" blok per task vóór de stappen
- Alle tasks op verify_required: ALIGNED_OR_PARTIAL (was deels ALIGNED — te strict
  voor ADR-stubs en multi-file edits)

Fixt forward-only: T-963 cancelled_by_self door DIVERGENT verifier-verdict.
Re-upload van dit bestand produceert tasks die door verify_task_against_plan
als ALIGNED of PARTIAL geclassificeerd kunnen worden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* PBI-67: Add review-plan support to Idea model and job config

- Add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum
- Add IDEA_REVIEW_PLAN config to job-config.ts with model=opus, thinking_budget=6000
- Create migration record for schema changes (applied via db push)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* PBI-67 Phase 2: Add update-idea-plan-reviewed MCP tool

- Create src/tools/update-idea-plan-reviewed.ts: saves review-log and transitions idea status to PLAN_REVIEWED
- Add PLAN_REVIEW_RESULT to IdeaLogType enum (both repos)
- Register tool in src/index.ts
- Update Prisma schemas (both repos): add plan_review_log and reviewed_at fields to Idea model
- Add REVIEWING_PLAN, PLAN_REVIEW_FAILED, PLAN_REVIEWED to IdeaStatus enum (MCP schema)
- Add IDEA_REVIEW_PLAN to ClaudeJobKind enum (MCP schema)
- Tool includes transaction safety and convergence metrics logging

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat(PBI-67): IDEA_REVIEW_PLAN Phases 3-6 — server actions, UI components, prompt & tests

- Phase 3: startReviewPlanJobAction, cancelIdeaJobAction, status transitions
  (REVIEWING_PLAN / PLAN_REVIEWED / PLAN_REVIEW_FAILED), status colors,
  job-card/jobs-column filters, idea-list status tabs
- Phase 4: review-plan-job.md prompt (multi-model orchestration with codex
  injection + active plan revision via update_idea_plan_md after each round),
  runbook, 13 unit tests
- Phase 5: ReviewLogViewer component (rounds, convergence, approval, issues),
  idea-detail integration, proper ReviewLog TypeScript types exported from component
- Phase 6.1: wait-for-job discriminator wired (IDEA_REVIEW_PLAN), plan-revision
  step made mandatory in prompt (was previously optional/missing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 03:35:02 +02:00

504 lines
16 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 { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
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 { ReviewLogViewer, type ReviewLog } from '@/components/ideas/review-log-viewer'
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',
reviewing_plan: 'REVIEWING_PLAN',
plan_review_failed: 'PLAN_REVIEW_FAILED',
plan_reviewed: 'PLAN_REVIEWED',
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
plan_review_log: ReviewLog | null // From DB JSON field, null if no review has been performed
products: ProductOption[]
logs: IdeaLog[]
questions: IdeaQuestion[]
userQuestions: IdeaUserQuestionDto[]
isDemo: boolean
initialTab: string
syncData: IdeaSyncData | null
}
export function IdeaDetailLayout({
idea,
grill_md,
plan_md,
plan_review_log,
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' && (
<div className="space-y-6">
<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}
/>
{plan_review_log && <ReviewLogViewer reviewLog={plan_review_log} />}
</div>
)}
{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>
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
disabled={!editable || pending || submitting}
>
{selectedSecondary.length > 0
? `Extra producten (${selectedSecondary.length})`
: 'Extra producten'}
</Button>
}
/>
<PopoverContent align="start" className="w-64 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>
))}
</PopoverContent>
</Popover>
</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>
)
}