Scrum4Me/components/ideas/idea-list.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

461 lines
16 KiB
TypeScript

'use client'
// IdeaList — top-level lijstpagina voor /ideas.
// - Strikt user_id-only data (server haalt al; client filtert binnen die set).
// - Filters: zoeken op titel, product-dropdown, status-multiselect.
// - Klik op rij navigeert naar /ideas/[id]. Acties (Grill / Make Plan /
// Materialiseer) staan in components/ideas/idea-row-actions.tsx (T-508).
// - DemoTooltip rondom muteer-acties; bulk-archive blijft achter feature-flag
// in T-508 en latere stories.
import { useMemo, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { getIdeaStatusBadge } from '@/lib/idea-status-colors'
import type { IdeaStatusApi } from '@/lib/idea-status'
import type { IdeaDto } from '@/lib/idea-dto'
import { debugProps } from '@/lib/debug'
import { createIdeaAction, archiveIdeaAction } from '@/actions/ideas'
import { IdeaRowActions } from '@/components/ideas/idea-row-actions'
// Reverse mapping voor het renderen van de status-badge — DTO bevat lowercase
// API-strings, het badge-helper verwacht DB-enum.
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',
}
interface ProductOption {
id: string
name: string
repo_url: string | null
}
type SortKey = 'code' | 'title' | 'product' | 'status'
interface IdeaListProps {
ideas: IdeaDto[]
products: ProductOption[]
isDemo: boolean
}
const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [
{ value: 'draft', label: 'Concept' },
{ value: 'grilling', label: 'Grillen' },
{ value: 'grilled', label: 'Gegrilld' },
{ value: 'planning', label: 'Plannen' },
{ value: 'plan_ready', label: 'Plan klaar' },
{ value: 'reviewing_plan', label: 'Plan beoordelen' },
{ value: 'planned', label: 'Gepland' },
{ value: 'grill_failed', label: 'Grill mislukt' },
{ value: 'plan_failed', label: 'Plan mislukt' },
{ value: 'plan_review_failed', label: 'Beoordeling mislukt' },
{ value: 'plan_reviewed', label: 'Plan beoordeeld' },
]
const STATUS_SORT_ORDER: Record<IdeaStatusApi, number> = {
draft: 0, grilling: 1, grilled: 2, planning: 3,
plan_ready: 4, reviewing_plan: 5, plan_reviewed: 6,
planned: 7, grill_failed: 8, plan_failed: 9, plan_review_failed: 10,
}
function SortHeader({
col,
label,
sortKey,
sortDir,
onSort,
}: {
col: SortKey
label: string
sortKey: SortKey
sortDir: 'asc' | 'desc'
onSort: (col: SortKey) => void
}) {
const active = sortKey === col
const Icon = active
? sortDir === 'asc' ? ArrowUp : ArrowDown
: ArrowUpDown
return (
<button
type="button"
onClick={() => onSort(col)}
className={cn(
'flex items-center gap-1 text-xs font-medium hover:text-foreground transition-colors',
active ? 'text-foreground' : 'text-muted-foreground'
)}
>
{label}
<Icon className="size-3" />
</button>
)
}
export function IdeaList({ ideas, products, isDemo }: IdeaListProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
// Filter state
const [search, setSearch] = useState('')
const [productFilter, setProductFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<Set<IdeaStatusApi>>(new Set())
// Sort state
const [sortKey, setSortKey] = useState<SortKey>('code')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
// Create-form state
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newProductId, setNewProductId] = useState<string>('')
// Quick-idea form state
const [showQuick, setShowQuick] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickDescription, setQuickDescription] = useState('')
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
const result = ideas.filter((idea) => {
if (q && !idea.title.toLowerCase().includes(q)) return false
if (productFilter !== 'all') {
if (productFilter === 'none') {
if (idea.product_id !== null) return false
} else {
const matchesPrimary = idea.product_id === productFilter
const matchesSecondary =
idea.secondary_products?.some((sp) => sp.product_id === productFilter) ?? false
if (!matchesPrimary && !matchesSecondary) return false
}
}
if (statusFilter.size > 0 && !statusFilter.has(idea.status)) return false
return true
})
const dir = sortDir === 'asc' ? 1 : -1
return [...result].sort((a, b) => {
switch (sortKey) {
case 'code': return dir * a.code.localeCompare(b.code)
case 'title': return dir * a.title.localeCompare(b.title)
case 'product': return dir * (a.product?.name ?? '').localeCompare(b.product?.name ?? '')
case 'status': return dir * a.status.localeCompare(b.status)
}
})
}, [ideas, search, productFilter, statusFilter, sortKey, sortDir])
const sorted = useMemo(() => {
return [...filtered].sort((a, b) => {
let cmp = 0
if (sortKey === 'code') {
cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true })
} else if (sortKey === 'title') {
cmp = a.title.localeCompare(b.title, 'nl')
} else if (sortKey === 'product') {
const aN = a.product?.name ?? ''
const bN = b.product?.name ?? ''
if (!aN && bN) return sortDir === 'asc' ? 1 : -1
if (aN && !bN) return sortDir === 'asc' ? -1 : 1
cmp = aN.localeCompare(bN, 'nl')
} else {
cmp = (STATUS_SORT_ORDER[a.status] ?? 99) - (STATUS_SORT_ORDER[b.status] ?? 99)
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [filtered, sortKey, sortDir])
function handleSort(col: SortKey) {
if (sortKey === col) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
} else {
setSortKey(col)
setSortDir('asc')
}
}
function toggleStatus(s: IdeaStatusApi) {
setStatusFilter((prev) => {
const next = new Set(prev)
if (next.has(s)) next.delete(s)
else next.add(s)
return next
})
}
function handleCreate() {
if (isDemo) return
const title = newTitle.trim()
if (!title) {
toast.error('Titel is verplicht')
return
}
startTransition(async () => {
const r = await createIdeaAction({
title,
description: newDescription.trim() || null,
product_id: newProductId || null,
})
if ('error' in r) {
toast.error(r.error)
return
}
toast.success(`Idee aangemaakt (${r.data?.code})`)
setNewTitle('')
setNewDescription('')
setNewProductId('')
setShowCreate(false)
router.refresh()
})
}
function handleQuickCreate() {
if (isDemo) return
const title = quickTitle.trim()
if (!title) {
toast.error('Titel is verplicht')
return
}
startTransition(async () => {
const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: null })
if ('error' in r) {
toast.error(r.error)
return
}
toast.success(`Idee aangemaakt (${r.data?.code})`)
setQuickTitle('')
setQuickDescription('')
setShowQuick(false)
router.refresh()
})
}
function handleArchive(id: string) {
if (isDemo) return
startTransition(async () => {
const r = await archiveIdeaAction(id)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success('Idee gearchiveerd')
router.refresh()
})
}
return (
<div className="space-y-4" {...debugProps('idea-list', 'IdeaList', 'components/ideas/idea-list.tsx')}>
{/* Top-bar: search + nieuw-knop */}
<div className="flex flex-wrap items-center gap-3" data-debug-id="idea-list__toolbar">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Zoek op titel..."
className="max-w-sm"
/>
<select
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="all">Alle producten</option>
<option value="none">Geen product</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<div className="ml-auto flex items-center gap-2">
<DemoTooltip show={isDemo}>
<Button
size="sm"
variant="outline"
onClick={() => setShowQuick((v) => !v)}
disabled={isDemo || isPending}
>
<Plus className="size-4 mr-1" />
Snel idee
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button
size="sm"
onClick={() => setShowCreate((v) => !v)}
disabled={isDemo || isPending}
>
<Plus className="size-4 mr-1" />
Nieuw idee
</Button>
</DemoTooltip>
</div>
</div>
{/* Status-chips als multi-select filter */}
<div className="flex flex-wrap gap-2">
{STATUS_FILTERS.map((s) => {
const active = statusFilter.has(s.value)
return (
<button
key={s.value}
type="button"
onClick={() => toggleStatus(s.value)}
className={`rounded-full border px-2.5 py-0.5 text-xs transition-colors ${
active
? 'bg-primary text-on-primary border-primary'
: 'bg-background text-muted-foreground border-input hover:bg-muted'
}`}
>
{s.label}
</button>
)
})}
</div>
{/* Snel idee form — geen product-dropdown */}
{showQuick && (
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
<Input
placeholder="Titel *"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleQuickCreate()}
/>
<Textarea
placeholder="Beschrijving (optioneel)"
value={quickDescription}
onChange={(e) => setQuickDescription(e.target.value)}
rows={2}
/>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowQuick(false)}>Annuleer</Button>
<Button size="sm" onClick={handleQuickCreate} disabled={isPending || !quickTitle.trim()}>Opslaan</Button>
</div>
</div>
)}
{/* Inline create form */}
{showCreate && (
<div className="rounded-md border border-input bg-surface-container p-4 space-y-3">
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Titel van het idee..."
disabled={isPending}
/>
<Textarea
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Korte beschrijving (optioneel)..."
rows={3}
disabled={isPending}
/>
<select
value={newProductId}
onChange={(e) => setNewProductId(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
disabled={isPending}
>
<option value="">Geen product (kan later worden gekoppeld)</option>
{products.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.repo_url ? '' : ' (geen repo)'}
</option>
))}
</select>
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowCreate(false)}
disabled={isPending}
>
Annuleer
</Button>
<Button size="sm" onClick={handleCreate} disabled={isPending || !newTitle.trim()}>
Aanmaken
</Button>
</div>
</div>
)}
{/* Tabel */}
{sorted.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">
{ideas.length === 0
? 'Nog geen ideeën — start hierboven met "Nieuw idee".'
: 'Geen ideeën die aan de filters voldoen.'}
</p>
) : (
<Table data-debug-id="idea-list__items">
<TableHeader>
<TableRow>
<TableHead className="w-24"><SortHeader col="code" label="Code" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead>
<TableHead><SortHeader col="title" label="Titel" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead>
<TableHead className="w-40"><SortHeader col="product" label="Product" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead>
<TableHead className="w-32"><SortHeader col="status" label="Status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead>
<TableHead className="w-72">Acties</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((idea) => {
const badge = getIdeaStatusBadge(API_TO_DB[idea.status])
return (
<TableRow
key={idea.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => router.push(`/ideas/${idea.id}`)}
>
<TableCell className="font-mono text-xs text-muted-foreground">
{idea.code}
</TableCell>
<TableCell className="font-medium">{idea.title}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{idea.product?.name ?? <span className="italic">geen</span>}
</TableCell>
<TableCell>
<span
className={badge.classes + (badge.pulse ? ' animate-pulse' : '')}
>
{badge.label}
</span>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<IdeaRowActions
idea={idea}
isDemo={isDemo}
onArchive={() => handleArchive(idea.id)}
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}