ST-9dymjaju: verwijder todo-routes, API-handler, component en verouderde tests

- Verwijder app/(app)/todos/page.tsx en app/(app)/todos/loading.tsx
- Verwijder app/api/todos/route.ts
- Verwijder components/todos/todo-list.tsx
- Verwijder lege mappen app/(app)/todos/, app/api/todos/, components/todos/
- Verwijder __tests__/api/todos.test.ts (test van verwijderde route)
- Verwijder POST /api/todos-sectie uit __tests__/api/security.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-06 09:05:18 +02:00
parent 9e45739786
commit 8568532b54
6 changed files with 0 additions and 961 deletions

View file

@ -41,8 +41,6 @@ import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route'
import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route'
import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route'
import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
import { POST as postTodo } from '@/app/api/todos/route'
const mockPrisma = prisma as unknown as {
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
sprint: { findFirst: ReturnType<typeof vi.fn> }
@ -57,7 +55,6 @@ const mockPrisma = prisma as unknown as {
findMany: ReturnType<typeof vi.fn>
}
storyLog: { create: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
$transaction: ReturnType<typeof vi.fn>
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
@ -419,46 +416,3 @@ describe('PATCH /api/tasks/:id', () => {
expect(res.status).toBe(200)
})
})
// ─── POST /api/todos ──────────────────────────────────────────────────────────
describe('POST /api/todos', () => {
// product_id is required by the Zod schema (z.string().min(1))
const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' }
// TC-TD-01
it('returns 401 when no valid token provided', async () => {
mockAuth.mockResolvedValue(UNAUTHORIZED)
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
expect(res.status).toBe(401)
})
// TC-TD-03
it('returns 403 for demo users', async () => {
mockAuth.mockResolvedValue(DEMO_AUTH)
const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY))
expect(res.status).toBe(403)
const data = await res.json()
expect(data.error).toBe('Niet beschikbaar in demo-modus')
})
// TC-TD-08
it('returns 404 when product_id belongs to another user', async () => {
mockAuth.mockResolvedValue(USER_2_AUTH)
mockPrisma.product.findFirst.mockResolvedValue(null)
const res = await postTodo(
makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' })
)
expect(res.status).toBe(404)
// Verify it queries by user_id, not productAccessFilter
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'prod-owned-by-user-1',
user_id: 'user-2',
}),
})
)
})
})

View file

@ -1,109 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: {
findFirst: vi.fn(),
},
todo: {
create: vi.fn(),
},
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { POST as postTodo } from '@/app/api/todos/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
todo: { create: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn>
const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' }
const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') }
function makeRequest(body: unknown): Request {
return new Request('http://localhost/api/todos', {
method: 'POST',
headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
describe('POST /api/todos', () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false })
mockPrisma.product.findFirst.mockResolvedValue(PRODUCT)
mockPrisma.todo.create.mockResolvedValue(TODO_RESULT)
})
// TC-TD-04
it('returns 422 when title is missing', async () => {
const res = await postTodo(makeRequest({ product_id: 'prod-1' }))
expect(res.status).toBe(422)
})
// TC-TD-05
it('returns 422 when title is empty string', async () => {
const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' }))
expect(res.status).toBe(422)
})
it('returns 422 when product_id is missing', async () => {
// product_id is required by the Zod schema (z.string().min(1))
const res = await postTodo(makeRequest({ title: 'My todo' }))
expect(res.status).toBe(422)
})
it('returns 422 when product_id is empty string', async () => {
const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' }))
expect(res.status).toBe(422)
})
// TC-TD-07
it('creates todo with valid product_id and returns 201', async () => {
const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
const data = await res.json()
expect(res.status).toBe(201)
expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' })
expect(data).toHaveProperty('created_at')
expect(mockPrisma.todo.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
user_id: 'user-1',
product_id: 'prod-1',
title: 'Test todo',
}),
})
)
})
it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => {
await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' }))
expect(mockPrisma.product.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
id: 'prod-1',
user_id: 'user-1',
archived: false,
}),
})
)
})
it('returns 404 when product does not exist or is archived', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' }))
expect(res.status).toBe(404)
})
})

View file

@ -1,19 +0,0 @@
export default function Loading() {
return (
<div className="p-6 max-w-2xl mx-auto w-full animate-pulse">
<div className="h-6 w-20 bg-border rounded mb-6" />
<div className="flex gap-3 mb-4">
<div className="h-8 w-32 bg-border/50 rounded-lg" />
<div className="flex-1" />
<div className="h-8 w-8 bg-border/50 rounded-lg" />
</div>
<div className="rounded-xl border border-border overflow-hidden">
<div className="h-10 bg-border/30" />
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="h-12 border-t border-border bg-border/20" />
))}
</div>
<div className="mt-4 h-24 bg-border/30 rounded-xl" />
</div>
)
}

View file

@ -1,47 +0,0 @@
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 { TodoList } from '@/components/todos/todo-list'
export default async function TodosPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const todos = await prisma.todo.findMany({
where: { user_id: session.userId, archived: false },
orderBy: { created_at: 'asc' },
include: { product: { select: { name: true } } },
})
const products = await prisma.product.findMany({
where: { ...productAccessFilter(session.userId), archived: false },
orderBy: { name: 'asc' },
include: {
pbis: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, title: true } },
},
})
return (
<div className="p-6 max-w-2xl mx-auto w-full">
<h1 className="text-xl font-medium text-foreground mb-6">Todo&apos;s</h1>
<TodoList
todos={todos.map(t => ({
id: t.id,
title: t.title,
description: t.description ?? null,
done: t.done,
created_at: t.created_at.toISOString(),
product_id: t.product_id ?? null,
product_name: t.product?.name ?? null,
}))}
products={products.map(p => ({
id: p.id,
name: p.name,
pbis: p.pbis,
}))}
isDemo={session.isDemo ?? false}
/>
</div>
)
}

View file

@ -1,53 +0,0 @@
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const bodySchema = z.object({
title: z.string().min(1, 'Titel is verplicht').max(500),
description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(),
product_id: z.string().min(1, 'Product is verplicht'),
})
export async function POST(request: Request) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
if (auth.isDemo) {
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
}
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = bodySchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
const product = await prisma.product.findFirst({
where: { id: parsed.data.product_id, user_id: auth.userId, archived: false },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const description = parsed.data.description?.trim() || null
const todo = await prisma.todo.create({
data: {
user_id: auth.userId,
product_id: parsed.data.product_id,
title: parsed.data.title,
description,
},
})
return Response.json(
{ id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at },
{ status: 201 },
)
}

View file

@ -1,687 +0,0 @@
'use client'
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import {
useReactTable,
getCoreRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type RowSelectionState,
type PaginationState,
} from '@tanstack/react-table'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
createTodoAction,
updateTodoAction,
archiveSelectedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
promoteTodoToIdeaAction,
} from '@/actions/todos'
interface Todo {
id: string
title: string
description: string | null
done: boolean
created_at: string
product_id: string | null
product_name: string | null
}
interface Pbi {
id: string
title: string
}
interface Product {
id: string
name: string
pbis: Pbi[]
}
interface TodoListProps {
todos: Todo[]
products: Product[]
isDemo: boolean
}
// Checkbox with indeterminate support for TanStack row selection
function IndeterminateCheckbox({
indeterminate,
className,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate ?? false
}, [indeterminate])
return (
<input
ref={ref}
type="checkbox"
className={cn('size-4 cursor-pointer accent-primary', className)}
{...props}
/>
)
}
function SaveButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" disabled={pending}>
{pending ? '…' : 'Opslaan'}
</Button>
)
}
// --- Promote to PBI dialog ---
function PromotePbiDialog({
todo,
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToPbiAction(_prev, fd)
if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() }
return result
},
undefined
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar PBI</h2>
<p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p>
<form action={formAction} className="space-y-3">
<input type="hidden" name="todoId" value={todo.id} />
<div className="space-y-1.5">
<label htmlFor="promote-pbi-title" className="text-sm font-medium">Titel</label>
<Input id="promote-pbi-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label htmlFor="promote-pbi-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-pbi-product"
name="productId"
required
defaultValue={todo.product_id ?? products[0]?.id}
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-pbi-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-pbi-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>
<option value="4">Laag</option>
</select>
</div>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
<Button type="submit" disabled={products.length === 0}>Promoveren</Button>
</div>
</form>
</div>
</div>
)
}
// --- Promote to Story dialog ---
function PromoteStoryDialog({
todo,
products,
onClose,
}: { todo: Todo; products: Product[]; onClose: () => void }) {
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [selectedProductId, setSelectedProductId] = useState(todo.product_id ?? products[0]?.id ?? '')
const selectedProduct = products.find(p => p.id === selectedProductId)
const [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToStoryAction(_prev, fd)
if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() }
return result
},
undefined
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar Story</h2>
<p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p>
<form action={formAction} className="space-y-3">
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="productId" value={selectedProductId} />
<div className="space-y-1.5">
<label htmlFor="promote-story-title" className="text-sm font-medium">Titel</label>
<Input id="promote-story-title" name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-product" className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
id="promote-story-product"
value={selectedProductId}
onChange={e => setSelectedProductId(e.target.value)}
className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-pbi" className="text-sm font-medium">PBI</label>
{!selectedProduct?.pbis.length ? (
<p className="text-sm text-muted-foreground">Maak eerst een PBI aan in dit product.</p>
) : (
<select id="promote-story-pbi" name="pbiId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
{selectedProduct.pbis.map(p => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
)}
</div>
<div className="space-y-1.5">
<label htmlFor="promote-story-priority" className="text-sm font-medium">Prioriteit</label>
<select id="promote-story-priority" name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background">
<option value="1">Kritiek</option>
<option value="2">Hoog</option>
<option value="3">Gemiddeld</option>
<option value="4">Laag</option>
</select>
</div>
{typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button>
<Button type="submit" disabled={!selectedProduct?.pbis.length}>Promoveren</Button>
</div>
</form>
</div>
</div>
)
}
// --- Promote to Idea dialog (M12 T-514) ---
// Geen extra inputs nodig — title/description komen uit de todo, en
// promoteTodoToIdeaAction archiveert de todo automatisch.
function PromoteIdeaDialog({
todo,
onClose,
}: { todo: Todo; onClose: () => void }) {
const router = useRouter()
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
useEffect(() => {
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [handleKey])
const [pending, startTransition] = useTransition()
function handleConfirm() {
startTransition(async () => {
const r = await promoteTodoToIdeaAction(todo.id)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success(`Idee aangemaakt (${r.idea_code})`)
onClose()
router.push(`/ideas/${r.idea_id}`)
})
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4">
<h2 className="font-medium text-foreground">Promoveer naar Idee</h2>
<p className="text-sm text-muted-foreground">
Maak een nieuw idee van &lsquo;<strong>{todo.title}</strong>&rsquo;. De Todo wordt
gearchiveerd; je kunt hem later terugvinden in de archief-filter.
</p>
<p className="text-xs text-muted-foreground">
Het idee start als <code>DRAFT</code>. Je kunt het daarna grillen, plannen, en
materialiseren tot een PBI.
</p>
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
Annuleren
</Button>
<Button onClick={handleConfirm} disabled={pending}>
{pending ? 'Bezig…' : 'Promoveren'}
</Button>
</div>
</div>
</div>
)
}
// --- Detail card ---
function TodoCard({
mode,
activeTodo,
products,
isDemo,
defaultProductId,
onSuccess,
onPromotePbi,
onPromoteStory,
onPromoteIdea,
}: {
mode: 'idle' | 'create' | 'edit'
activeTodo: Todo | null
products: Product[]
isDemo: boolean
defaultProductId: string
onSuccess: () => void
onPromotePbi: (todo: Todo) => void
onPromoteStory: (todo: Todo) => void
onPromoteIdea: (todo: Todo) => void
}) {
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
useEffect(() => {
if (createState && 'success' in createState && createState.success) onSuccess()
}, [createState, onSuccess])
useEffect(() => {
if (editState && 'success' in editState && editState.success) onSuccess()
}, [editState, onSuccess])
if (mode === 'idle') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">Selecteer een rij of klik op + om te beginnen.</p>
</div>
)
}
if (mode === 'create') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Nieuwe todo</p>
<form action={createFormAction} className="space-y-3">
<div className="flex gap-3">
<select
name="productId"
defaultValue={defaultProductId}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
placeholder="Titel…"
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
<Textarea
name="description"
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
{typeof createState?.error === 'string' && (
<p className="text-xs text-error">{createState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// Edit mode
if (!activeTodo) return null
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Todo bewerken</p>
<form action={editFormAction} className="space-y-3">
<input type="hidden" name="id" value={activeTodo.id} />
<div className="flex gap-3">
<select
name="productId"
defaultValue={activeTodo.product_id ?? ''}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
defaultValue={activeTodo.title}
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
<Textarea
name="description"
defaultValue={activeTodo.description ?? ''}
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
disabled={isDemo}
maxLength={2000}
rows={4}
/>
<label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none">
<input
type="checkbox"
name="done"
defaultChecked={activeTodo.done}
disabled={isDemo}
className="size-4 accent-primary cursor-pointer"
/>
Afgerond
</label>
{typeof editState?.error === 'string' && (
<p className="text-xs text-error">{editState.error}</p>
)}
<div className="flex items-center gap-2">
{!isDemo && (
<>
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteIdea(activeTodo)}>
Idee
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
PBI
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteStory(activeTodo)}>
Story
</Button>
</>
)}
<div className="flex-1" />
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// --- Main component ---
export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [isPending, startTransition] = useTransition()
const [selectedProductId, setSelectedProductId] = useState('all')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
const [activeRowId, setActiveRowId] = useState<string | null>(null)
const [mode, setMode] = useState<'idle' | 'create'>('idle')
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
const [promoteIdea, setPromoteIdea] = useState<Todo | null>(null)
const filtered = useMemo(() => {
if (selectedProductId === 'all') return todos
if (selectedProductId === '') return todos.filter(t => t.product_id === null)
return todos.filter(t => t.product_id === selectedProductId)
}, [todos, selectedProductId])
useEffect(() => {
setPagination(p => ({ ...p, pageIndex: 0 }))
setRowSelection({})
}, [selectedProductId])
const columns = useMemo<ColumnDef<Todo>[]>(() => [
{
id: 'select',
header: ({ table }) => (
<IndeterminateCheckbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onChange={e => table.toggleAllPageRowsSelected(e.target.checked)}
/>
),
cell: ({ row }) => (
<IndeterminateCheckbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={e => row.toggleSelected(e.target.checked)}
onClick={e => e.stopPropagation()}
/>
),
},
{
accessorKey: 'title',
header: 'Titel',
cell: ({ row }) => (
<p className={cn(
'line-clamp-2 text-sm leading-snug',
row.original.done && 'line-through text-muted-foreground'
)}>
{row.original.title}
</p>
),
},
{
accessorKey: 'product_name',
header: 'Product',
cell: ({ row }) => row.original.product_name ? (
<Badge variant="outline" className="text-xs font-normal">{row.original.product_name}</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'created_at',
header: 'Datum',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground whitespace-nowrap">
{new Date(row.original.created_at).toLocaleDateString('nl-NL')}
</span>
),
},
], [])
const table = useReactTable({
data: filtered,
columns,
state: { rowSelection, pagination },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const selectedRows = table.getSelectedRowModel().rows
const selectedCount = selectedRows.length
const { pageIndex, pageSize } = table.getState().pagination
const totalRows = filtered.length
const start = totalRows === 0 ? 0 : pageIndex * pageSize + 1
const end = Math.min((pageIndex + 1) * pageSize, totalRows)
const activeTodo = todos.find(t => t.id === activeRowId) ?? null
const cardMode = mode === 'create' ? 'create' : activeTodo ? 'edit' : 'idle'
const defaultProductId = selectedProductId !== 'all' ? selectedProductId : ''
const handleCancel = useCallback(() => {
setActiveRowId(null)
setMode('idle')
}, [])
function handleRowClick(todo: Todo) {
setActiveRowId(prev => prev === todo.id ? null : todo.id)
setMode('idle')
}
function handleNew() {
setActiveRowId(null)
setRowSelection({})
setMode('create')
}
function handleBulkArchive() {
const ids = selectedRows.map(r => r.original.id)
startTransition(async () => {
const result = await archiveSelectedTodosAction(ids)
if (result?.error) {
toast.error(typeof result.error === 'string' ? result.error : 'Archiveren mislukt')
} else {
const n = ids.length
toast.success(`${n} todo${n === 1 ? '' : "'s"} gearchiveerd`)
setRowSelection({})
setActiveRowId(null)
setMode('idle')
}
})
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={selectedProductId}
onChange={e => setSelectedProductId(e.target.value)}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
>
<option value="all">Alles</option>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<div className="flex-1" />
{selectedCount > 0 && !isDemo && (
<Button variant="outline" size="sm" onClick={handleBulkArchive} disabled={isPending}>
Archiveer geselecteerde ({selectedCount})
</Button>
)}
<DemoTooltip show={isDemo}>
<Button size="sm" onClick={handleNew} disabled={isDemo}>+</Button>
</DemoTooltip>
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (
<TableRow key={hg.id} className="hover:bg-transparent">
{hg.headers.map(header => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className={cn(
'cursor-pointer hover:bg-surface-container-low',
activeRowId === row.original.id
? 'bg-primary/5'
: row.getIsSelected()
? 'bg-primary/10'
: ''
)}
onClick={() => handleRowClick(row.original)}
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={4} className="h-32 text-center text-sm text-muted-foreground">
{todos.length === 0
? "Nog geen todo's. Gebruik + om er een aan te maken."
: "Geen todo's voor deze selectie."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalRows > pageSize && (
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{start}{end} van {totalRows}</span>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}></Button>
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}></Button>
</div>
</div>
)}
{/* Detail card */}
<TodoCard
key={activeRowId ?? (mode === 'create' ? 'create' : 'idle')}
mode={cardMode}
activeTodo={activeTodo}
products={products}
isDemo={isDemo}
defaultProductId={defaultProductId}
onSuccess={handleCancel}
onPromotePbi={setPromotePbi}
onPromoteStory={setPromoteStory}
onPromoteIdea={setPromoteIdea}
/>
{promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
)}
{promoteStory && (
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
)}
{promoteIdea && (
<PromoteIdeaDialog todo={promoteIdea} onClose={() => setPromoteIdea(null)} />
)}
</div>
)
}