* feat(ideas): multi-select secundaire producten + badges in IdeaDetailLayout Voegt checkbox-lijst toe voor extra producten (exclusief primaire) in de Idee-tab, geïntegreerd in bestaande save/reset flow via updateSecondaryProductsAction. Toont secundaire product-badges in de detail-header. Bevat ook schema/dto/action-dependencies (IdeaProduct junction, secondary_products in IdeaDto). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(ideas): lijst-filter matcht op primair én secundaire producten Breidt productFilter-logica in IdeaList uit: naast product_id wordt ook idea.secondary_products gecheckt, zodat ideeën zichtbaar blijven bij filteren op een secundair gekoppeld product. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
312 lines
10 KiB
TypeScript
312 lines
10 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 } 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') {
|
|
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
|
|
})
|
|
}, [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>
|
|
)
|
|
}
|