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:
parent
9d3a993f2a
commit
1ba9feac1a
4 changed files with 314 additions and 89 deletions
55
components/ideas/download-md-button.tsx
Normal file
55
components/ideas/download-md-button.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
// DownloadMdButton — download grill_md of plan_md als .md-bestand.
|
||||
// Demo MAG downloaden (read-only). Server-action returnt md-string; client
|
||||
// bouwt een Blob + anchor + click().
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { downloadIdeaMdAction } from '@/actions/ideas'
|
||||
|
||||
interface Props {
|
||||
ideaId: string
|
||||
kind: 'grill' | 'plan'
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
export function DownloadMdButton({ ideaId, kind, hasContent }: Props) {
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
function handleClick() {
|
||||
startTransition(async () => {
|
||||
const r = await downloadIdeaMdAction(ideaId, kind)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
if (!r.data) return
|
||||
const blob = new Blob([r.data.markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = r.data.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClick}
|
||||
disabled={pending || !hasContent}
|
||||
title={hasContent ? `Download ${kind}_md` : 'Geen content'}
|
||||
>
|
||||
<Download className="size-3.5 mr-1" />
|
||||
.md
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
85
components/ideas/idea-pbi-link-card.tsx
Normal file
85
components/ideas/idea-pbi-link-card.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
'use client'
|
||||
|
||||
// IdeaPbiLinkCard — toont de gekoppelde PBI bij PLANNED. Bij "stale link"
|
||||
// (status===PLANNED maar pbi_id===null, want PBI elders verwijderd via
|
||||
// de SetNull FK) tonen we de Re-link-banner.
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ExternalLink, Link2Off } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { relinkIdeaPlanAction } from '@/actions/ideas'
|
||||
import type { IdeaDto } from '@/lib/idea-dto'
|
||||
|
||||
interface Props {
|
||||
idea: IdeaDto
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
export function IdeaPbiLinkCard({ idea, isDemo }: Props) {
|
||||
const router = useRouter()
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
if (idea.status !== 'planned') return null
|
||||
|
||||
if (idea.pbi && idea.product_id) {
|
||||
return (
|
||||
<div className="rounded-md border border-status-done/30 bg-status-done/10 p-4 flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-xs uppercase tracking-wide text-status-done font-medium">
|
||||
Gepland
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Gematerialiseerd als{' '}
|
||||
<Link
|
||||
href={`/products/${idea.product_id}/backlog#pbi-${idea.pbi.code}`}
|
||||
className="font-medium text-status-done hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{idea.pbi.code} — {idea.pbi.title}
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stale link — pbi_id === null maar status nog PLANNED.
|
||||
function handleRelink() {
|
||||
if (isDemo) return
|
||||
startTransition(async () => {
|
||||
const r = await relinkIdeaPlanAction(idea.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success('Idee terug naar PLAN_READY — open de Plan-tab.')
|
||||
router.refresh()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2Off className="size-4 text-status-blocked" />
|
||||
<p className="text-sm font-medium text-status-blocked">
|
||||
De gekoppelde PBI bestaat niet meer
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Klik om dit idee terug naar PLAN_READY te zetten en opnieuw te materialiseren.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRelink}
|
||||
disabled={isDemo || pending}
|
||||
>
|
||||
Plan opnieuw beschikbaar maken
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
163
components/ideas/idea-timeline.tsx
Normal file
163
components/ideas/idea-timeline.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use client'
|
||||
|
||||
// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion entries.
|
||||
// Server-component zou ook kunnen, maar we mounten dit binnen de client-side
|
||||
// detail-layout dus client is simpler (geen rsc-boundary doorbreken).
|
||||
//
|
||||
// Iconen + kleur per log-type voor snelle herkenning.
|
||||
|
||||
import {
|
||||
ClipboardList,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
RefreshCw,
|
||||
StickyNote,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
|
||||
import type { IdeaLogType } from '@prisma/client'
|
||||
|
||||
export interface TimelineLog {
|
||||
id: string
|
||||
type: string
|
||||
content: string
|
||||
metadata: unknown
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TimelineQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[] | null
|
||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
answer: string | null
|
||||
created_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
logs: TimelineLog[]
|
||||
questions: TimelineQuestion[]
|
||||
}
|
||||
|
||||
const LOG_ICON: Record<IdeaLogType, React.ReactNode> = {
|
||||
DECISION: <Lightbulb className="size-4" />,
|
||||
NOTE: <StickyNote className="size-4" />,
|
||||
GRILL_RESULT: <FileText className="size-4" />,
|
||||
PLAN_RESULT: <ClipboardList className="size-4" />,
|
||||
STATUS_CHANGE: <RefreshCw className="size-4" />,
|
||||
JOB_EVENT: <Wrench className="size-4" />,
|
||||
}
|
||||
|
||||
const LOG_LABEL: Record<IdeaLogType, string> = {
|
||||
DECISION: 'Beslissing',
|
||||
NOTE: 'Notitie',
|
||||
GRILL_RESULT: 'Grill-resultaat',
|
||||
PLAN_RESULT: 'Plan-resultaat',
|
||||
STATUS_CHANGE: 'Status',
|
||||
JOB_EVENT: 'Job-event',
|
||||
}
|
||||
|
||||
const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
|
||||
open: 'Open',
|
||||
answered: 'Beantwoord',
|
||||
cancelled: 'Geannuleerd',
|
||||
expired: 'Verlopen',
|
||||
}
|
||||
|
||||
export function IdeaTimeline({ logs, questions }: Props) {
|
||||
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 (
|
||||
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
|
||||
{merged.map((entry, i) => {
|
||||
const time = new Date(entry.created_at).toLocaleString()
|
||||
|
||||
if (entry.kind === 'log') {
|
||||
const type = entry.data.type as IdeaLogType
|
||||
return (
|
||||
<li key={`l-${entry.data.id}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
|
||||
{LOG_ICON[type] ?? <StickyNote className="size-4" />}
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">
|
||||
{LOG_LABEL[type] ?? type}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
|
||||
{entry.data.metadata != null &&
|
||||
typeof entry.data.metadata === 'object' &&
|
||||
Object.keys(entry.data.metadata as object).length > 0 ? (
|
||||
<details className="text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer">metadata</summary>
|
||||
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
|
||||
{JSON.stringify(entry.data.metadata, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const q = entry.data
|
||||
return (
|
||||
<li key={`q-${q.id}-${i}`} className="relative">
|
||||
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
|
||||
<HelpCircle className="size-4" />
|
||||
</span>
|
||||
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium uppercase tracking-wide">Vraag</span>
|
||||
<span>·</span>
|
||||
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
|
||||
<span>·</span>
|
||||
<time>{time}</time>
|
||||
</div>
|
||||
<p className="text-sm">{q.question}</p>
|
||||
{q.options && q.options.length > 0 ? (
|
||||
<ul className="text-xs text-muted-foreground list-disc list-inside">
|
||||
{q.options.map((o, ii) => (
|
||||
<li key={ii}>{o}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{q.answer ? (
|
||||
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
|
||||
Antwoord
|
||||
</span>
|
||||
{q.answer}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue