ui: idea-timeline + pbi-link-card + download-md-button (M12 T-512)

components/ideas/idea-timeline.tsx:
- Chronological merge of IdeaLog + ClaudeQuestion (sorted desc by created_at)
- Per-entry icon by log-type (DECISION/NOTE/GRILL_RESULT/PLAN_RESULT/
  STATUS_CHANGE/JOB_EVENT) + question-status label
- MD3-tokens, vertical timeline rail (border-left + dots)
- Question entries show options + answer (border-left highlight)
- Metadata expansion via <details> for log entries

components/ideas/idea-pbi-link-card.tsx:
- PLANNED + pbi present: green status-done card with PBI link
- PLANNED + pbi removed (FK SetNull): blocked-color banner with
  "Plan opnieuw beschikbaar maken" → relinkIdeaPlanAction
- Demo blocked on relink

components/ideas/download-md-button.tsx:
- Calls downloadIdeaMdAction → builds Blob + anchor + click()
- Filename: {idea.code}-{kind}.md
- Demo MAY use it (read-only)

components/ideas/idea-detail-layout.tsx:
- Replaces inline placeholders with extracted components
- Md tabs gain Download (.md) button + Edit button row

Tests: 546/546 still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 21:39:33 +02:00
parent 9d3a993f2a
commit 1ba9feac1a
4 changed files with 314 additions and 89 deletions

View file

@ -23,6 +23,9 @@ import type { IdeaDto } from '@/lib/idea-dto'
import { updateIdeaAction, archiveIdeaAction } 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 { DownloadMdButton } from '@/components/ideas/download-md-button'
const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = {
draft: 'DRAFT',
@ -153,32 +156,8 @@ export function IdeaDetailLayout({
<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>
)}
{/* PBI-link card / Re-link banner bij PLANNED */}
<IdeaPbiLinkCard idea={idea} isDemo={isDemo} />
{/* Tab-switcher */}
<nav className="border-b border-input flex gap-1">
@ -232,7 +211,7 @@ export function IdeaDetailLayout({
ideaId={idea.id}
/>
)}
{tab === 'timeline' && <TimelinePlaceholder logs={logs} questions={questions} />}
{tab === 'timeline' && <IdeaTimeline logs={logs} questions={questions} />}
</div>
)
}
@ -390,74 +369,17 @@ function MdSection({ kind, markdown, editable, ideaId }: MdProps) {
return (
<div className="space-y-3">
{editable && (
<div className="flex justify-end">
<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>
)}
)}
</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>
)
}
// ---------------------------------------------------------------------------
// 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>
)
}