diff --git a/components/ideas/download-md-button.tsx b/components/ideas/download-md-button.tsx new file mode 100644 index 0000000..6e1a255 --- /dev/null +++ b/components/ideas/download-md-button.tsx @@ -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 ( + + + .md + + ) +} diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx index d46b126..0e12ac8 100644 --- a/components/ideas/idea-detail-layout.tsx +++ b/components/ideas/idea-detail-layout.tsx @@ -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[0]> = { draft: 'DRAFT', @@ -153,32 +156,8 @@ export function IdeaDetailLayout({ - {/* PBI-link card bij PLANNED — placeholder voor T-512 */} - {idea.status === 'planned' && idea.pbi && ( - - - Gematerialiseerd als{' '} - - {idea.pbi.code} — {idea.pbi.title} - - - - )} - {idea.status === 'planned' && !idea.pbi && ( - - - De gekoppelde PBI bestaat niet meer. Klik om dit idee terug naar - PLAN_READY te zetten en opnieuw te materialiseren. - - - )} + {/* PBI-link card / Re-link banner bij PLANNED */} + {/* Tab-switcher */} @@ -232,7 +211,7 @@ export function IdeaDetailLayout({ ideaId={idea.id} /> )} - {tab === 'timeline' && } + {tab === 'timeline' && } ) } @@ -390,74 +369,17 @@ function MdSection({ kind, markdown, editable, ideaId }: MdProps) { return ( - {editable && ( - + + + {editable && ( setEditing(true)}> Bewerk - - )} + )} + {markdown} ) } - -// --------------------------------------------------------------------------- -// 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 ( - - Nog geen activiteit op dit idee. - - ) - } - - return ( - - {merged.map((entry, i) => ( - - - - {entry.kind === 'log' ? entry.data.type : `vraag · ${entry.data.status}`} - - · - {new Date(entry.created_at).toLocaleString()} - - - {entry.kind === 'log' ? entry.data.content : entry.data.question} - - {entry.kind === 'question' && entry.data.answer && ( - - {entry.data.answer} - - )} - - ))} - - ) -} diff --git a/components/ideas/idea-pbi-link-card.tsx b/components/ideas/idea-pbi-link-card.tsx new file mode 100644 index 0000000..fc7f7fe --- /dev/null +++ b/components/ideas/idea-pbi-link-card.tsx @@ -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 ( + + + + Gepland + + + Gematerialiseerd als{' '} + + {idea.pbi.code} — {idea.pbi.title} + + + + + + ) + } + + // 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 ( + + + + + De gekoppelde PBI bestaat niet meer + + + + Klik om dit idee terug naar PLAN_READY te zetten en opnieuw te materialiseren. + + + Plan opnieuw beschikbaar maken + + + ) +} diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx new file mode 100644 index 0000000..2211655 --- /dev/null +++ b/components/ideas/idea-timeline.tsx @@ -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 = { + DECISION: , + NOTE: , + GRILL_RESULT: , + PLAN_RESULT: , + STATUS_CHANGE: , + JOB_EVENT: , +} + +const LOG_LABEL: Record = { + DECISION: 'Beslissing', + NOTE: 'Notitie', + GRILL_RESULT: 'Grill-resultaat', + PLAN_RESULT: 'Plan-resultaat', + STATUS_CHANGE: 'Status', + JOB_EVENT: 'Job-event', +} + +const QUESTION_STATUS_LABEL: Record = { + 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 ( + + Nog geen activiteit op dit idee. + + ) + } + + return ( + + {merged.map((entry, i) => { + const time = new Date(entry.created_at).toLocaleString() + + if (entry.kind === 'log') { + const type = entry.data.type as IdeaLogType + return ( + + + {LOG_ICON[type] ?? } + + + + + {LOG_LABEL[type] ?? type} + + · + {time} + + {entry.data.content} + {entry.data.metadata != null && + typeof entry.data.metadata === 'object' && + Object.keys(entry.data.metadata as object).length > 0 ? ( + + metadata + + {JSON.stringify(entry.data.metadata, null, 2)} + + + ) : null} + + + ) + } + + const q = entry.data + return ( + + + + + + + Vraag + · + {QUESTION_STATUS_LABEL[q.status]} + · + {time} + + {q.question} + {q.options && q.options.length > 0 ? ( + + {q.options.map((o, ii) => ( + {o} + ))} + + ) : null} + {q.answer ? ( + + + Antwoord + + {q.answer} + + ) : null} + + + ) + })} + + ) +}
- Gematerialiseerd als{' '} - - {idea.pbi.code} — {idea.pbi.title} - -
- De gekoppelde PBI bestaat niet meer. Klik om dit idee terug naar - PLAN_READY te zetten en opnieuw te materialiseren. -
{markdown}
- Nog geen activiteit op dit idee. -
- {entry.kind === 'log' ? entry.data.content : entry.data.question} -
- {entry.data.answer} -
+ Gepland +
+ Gematerialiseerd als{' '} + + {idea.pbi.code} — {idea.pbi.title} + + +
+ De gekoppelde PBI bestaat niet meer +
+ Klik om dit idee terug naar PLAN_READY te zetten en opnieuw te materialiseren. +
+ Nog geen activiteit op dit idee. +
{entry.data.content}
+ {JSON.stringify(entry.data.metadata, null, 2)} +
{q.question}
+ + Antwoord + + {q.answer} +