ui: /ideas list page + IdeaList table + row-actions skeleton (M12 T-507)

app/(app)/ideas/page.tsx (server-component):
- user_id-only fetch (no productAccessFilter — Idee is privé)
- products fetched with productAccessFilter for filter-dropdown + create-form

components/ideas/idea-list.tsx (client-component):
- Search by title, product-dropdown filter, status multi-chip filter
- Inline create form with title/description/product (optional)
- Native shadcn Table + status badge via getIdeaStatusBadge (T-509)
- Row click navigates to /ideas/[id]
- Sonner toasts for success/error; router.refresh() after mutations
- DemoTooltip + disabled on Nieuw + Archive
- Empty-state + filtered-empty messaging

components/ideas/idea-row-actions.tsx (placeholder for T-508):
- "Open" navigation + "Archive" button only — Grill / Make Plan /
  Materialiseer come in T-508 with full disabled-rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-04 21:30:56 +02:00
parent 006d803a16
commit 2eb0f33068
3 changed files with 403 additions and 0 deletions

48
app/(app)/ideas/page.tsx Normal file
View file

@ -0,0 +1,48 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { ideaToDto } from '@/lib/idea-dto'
import { IdeaList } from '@/components/ideas/idea-list'
export const dynamic = 'force-dynamic'
export default async function IdeasPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
// M12: idee is strikt user_id-only (geen productAccessFilter — Q8).
const ideas = await prisma.idea.findMany({
where: { user_id: session.userId, archived: false },
orderBy: { created_at: 'desc' },
include: { product: { select: { id: true, name: true, repo_url: true } } },
take: 200,
})
// Productenlijst voor de filter-dropdown + voor "Nieuw idee"-form.
// Producten zijn product-scoped (kan team-shared zijn) — productAccessFilter
// is hier dus wél juist.
const products = await prisma.product.findMany({
where: { ...productAccessFilter(session.userId), archived: false },
orderBy: { name: 'asc' },
select: { id: true, name: true, repo_url: true },
})
return (
<div className="p-6 max-w-5xl mx-auto w-full">
<header className="mb-6 flex items-baseline justify-between">
<h1 className="text-xl font-medium text-foreground">Ideeën</h1>
<p className="text-sm text-muted-foreground">
Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien.
</p>
</header>
<IdeaList
ideas={ideas.map((i) => ideaToDto(i))}
products={products}
isDemo={session.isDemo ?? false}
/>
</div>
)
}

View file

@ -0,0 +1,306 @@
'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 } 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 {
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 { 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',
planned: 'PLANNED',
}
interface ProductOption {
id: string
name: string
repo_url: string | null
}
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: 'planned', label: 'Gepland' },
{ value: 'grill_failed', label: 'Grill mislukt' },
{ value: 'plan_failed', label: 'Plan mislukt' },
]
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())
// Create-form state
const [showCreate, setShowCreate] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newDescription, setNewDescription] = useState('')
const [newProductId, setNewProductId] = useState<string>('')
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
return ideas.filter((idea) => {
if (q && !idea.title.toLowerCase().includes(q)) return false
if (productFilter !== 'all') {
if (productFilter === 'none' && idea.product_id !== null) return false
if (productFilter !== 'none' && idea.product_id !== productFilter) return false
}
if (statusFilter.size > 0 && !statusFilter.has(idea.status)) return false
return true
})
}, [ideas, search, productFilter, statusFilter])
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 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">
{/* Top-bar: search + nieuw-knop */}
<div className="flex flex-wrap items-center gap-3">
<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">
<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>
{/* 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 */}
{filtered.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>
<TableHeader>
<TableRow>
<TableHead className="w-24">Code</TableHead>
<TableHead>Titel</TableHead>
<TableHead className="w-40">Product</TableHead>
<TableHead className="w-32">Status</TableHead>
<TableHead className="w-72">Acties</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.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>
)
}

View file

@ -0,0 +1,49 @@
'use client'
// IdeaRowActions — placeholder shell voor M12 T-507. De volledige
// disabled-rules + Grill/Make-Plan/Materialiseer-knoppen komen in T-508.
//
// Voor nu: alleen een "Open" link en een Archive-knop, zodat de lijst
// compileert en navigatie + archief al werken.
import { useRouter } from 'next/navigation'
import { Archive, ArrowRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import type { IdeaDto } from '@/lib/idea-dto'
interface IdeaRowActionsProps {
idea: IdeaDto
isDemo: boolean
onArchive: () => void
}
export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) {
const router = useRouter()
return (
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => router.push(`/ideas/${idea.id}`)}
>
Open
<ArrowRight className="ml-1 size-3.5" />
</Button>
<DemoTooltip show={isDemo}>
<Button
size="sm"
variant="ghost"
onClick={onArchive}
disabled={isDemo}
aria-label="Archiveer idee"
title="Archiveer"
>
<Archive className="size-4" />
</Button>
</DemoTooltip>
</div>
)
}