diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx new file mode 100644 index 0000000..142e376 --- /dev/null +++ b/app/(app)/ideas/page.tsx @@ -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(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 ( +
+
+

Ideeën

+

+ Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien. +

+
+ + ideaToDto(i))} + products={products} + isDemo={session.isDemo ?? false} + /> +
+ ) +} diff --git a/components/ideas/idea-list.tsx b/components/ideas/idea-list.tsx new file mode 100644 index 0000000..e457a02 --- /dev/null +++ b/components/ideas/idea-list.tsx @@ -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[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('all') + const [statusFilter, setStatusFilter] = useState>(new Set()) + + // Create-form state + const [showCreate, setShowCreate] = useState(false) + const [newTitle, setNewTitle] = useState('') + const [newDescription, setNewDescription] = useState('') + const [newProductId, setNewProductId] = useState('') + + 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 ( +
+ {/* Top-bar: search + nieuw-knop */} +
+ setSearch(e.target.value)} + placeholder="Zoek op titel..." + className="max-w-sm" + /> + +
+ + + +
+
+ + {/* Status-chips als multi-select filter */} +
+ {STATUS_FILTERS.map((s) => { + const active = statusFilter.has(s.value) + return ( + + ) + })} +
+ + {/* Inline create form */} + {showCreate && ( +
+ setNewTitle(e.target.value)} + placeholder="Titel van het idee..." + disabled={isPending} + /> +