ST-1241: Verwijder frontend todo-UI (page, component, navbar en marketing page) (#133)

* feat(cleanup): verwijder Todo's navlink en todo-referenties uit marketing page [cmotto5ia000nx3178lq6xk8d]

- nav-bar.tsx: Todo's navLink verwijderd; Ideas-link blijft staan
- app/page.tsx: /todos quick-access link, feature-entry en /api/todos API-doc verwijderd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(cleanup): verwijder app/(app)/todos/ en components/todos/ [cmottjvzo000cx3172472cu4g]

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-06 12:24:00 +02:00 committed by GitHub
parent 11937d8a8d
commit 628fbd7e7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 0 additions and 765 deletions

View file

@ -143,7 +143,6 @@ export function NavBar({
: disabledSpan('Solo')}
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
{navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))}
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
</nav>
</div>

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>
)
}