feat(ST-509): rebuild todo list as TanStack Data Table

- @tanstack/react-table voor kolommen, paginering en rij-selectie
- Kolommen: multi-select checkbox, titel (line-clamp-2), productnaam-badge, datum
- Toolbar: product-filter dropdown, bulk-archiveer knop (telt selectie), + knop
- Paginering: 10 rijen per pagina met paginatelling (x–y van n)
- Rij-klik opent detail-kaart (placeholder; volgt in ST-510)
- Promote dialogs behouden voor gebruik in ST-510

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-25 19:55:36 +02:00
parent 6fa768aabe
commit 5dc8033b85
3 changed files with 268 additions and 148 deletions

View file

@ -1,18 +1,28 @@
'use client'
import { useState, useTransition, useActionState, useEffect, useRef, useCallback } from 'react'
import { useFormStatus } from 'react-dom'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Input } from '@/components/ui/input'
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
import {
createTodoAction,
toggleTodoAction,
archiveCompletedTodosAction,
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 { DemoTooltip } from '@/components/shared/demo-tooltip'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import {
archiveSelectedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
} from '@/actions/todos'
import { useActionState } from 'react'
interface Todo {
id: string
@ -40,63 +50,23 @@ interface TodoListProps {
isDemo: boolean
}
function QuickInput({
products,
isDemo,
selectedProductId,
onProductChange,
}: {
products: Product[]
isDemo: boolean
selectedProductId: string
onProductChange: (id: string) => void
}) {
const [state, formAction] = useActionState(createTodoAction, undefined)
const titleRef = useRef<HTMLInputElement>(null)
// 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 (state?.success && titleRef.current) titleRef.current.value = ''
}, [state])
if (ref.current) ref.current.indeterminate = indeterminate ?? false
}, [indeterminate])
return (
<div className="mb-6 space-y-1">
<form action={formAction} className="flex gap-2">
<select
name="productId"
value={selectedProductId}
onChange={e => onProductChange(e.target.value)}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="all">Alles</option>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
ref={titleRef}
name="title"
placeholder={isDemo ? 'Alleen-lezen in demo' : 'Nieuwe todo… (Enter om op te slaan)'}
disabled={isDemo}
className="flex-1"
autoComplete="off"
<input
ref={ref}
type="checkbox"
className={cn('size-4 cursor-pointer accent-primary', className)}
{...props}
/>
<DemoTooltip show={isDemo}>
<QuickSubmitButton isDemo={isDemo} />
</DemoTooltip>
</form>
{typeof state?.error === 'string' && (
<p className="text-xs text-error">{state.error}</p>
)}
</div>
)
}
function QuickSubmitButton({ isDemo }: { isDemo: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || isDemo}>
{pending ? '…' : 'Toevoegen'}
</Button>
)
}
@ -247,106 +217,221 @@ function PromoteStoryDialog({
)
}
// --- Main list ---
// --- Main component ---
export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [, startTransition] = useTransition()
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 [selectedProductId, setSelectedProductId] = useState('all')
const filtered =
selectedProductId === 'all'
? todos
: selectedProductId === ''
? todos.filter(t => t.product_id === null)
: todos.filter(t => t.product_id === selectedProductId)
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])
const open = filtered.filter(t => !t.done)
const done = filtered.filter(t => t.done)
useEffect(() => {
setPagination(p => ({ ...p, pageIndex: 0 }))
setRowSelection({})
}, [selectedProductId])
function handleToggle(id: string, current: boolean) {
startTransition(async () => {
await toggleTodoAction(id, !current)
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
function handleRowClick(todo: Todo) {
setActiveRowId(prev => prev === todo.id ? null : todo.id)
setMode('idle')
}
function handleArchive() {
function handleNew() {
setActiveRowId(null)
setRowSelection({})
setMode('create')
}
function handleBulkArchive() {
const ids = selectedRows.map(r => r.original.id)
startTransition(async () => {
const result = await archiveCompletedTodosAction()
if (result && 'error' in result) toast.error(result.error ?? 'Archiveren mislukt')
else toast.success('Afgeronde todos gearchiveerd')
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)
}
})
}
return (
<div className="space-y-4">
<QuickInput
products={products}
isDemo={isDemo}
selectedProductId={selectedProductId}
onProductChange={setSelectedProductId}
/>
{/* 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>
{filtered.length === 0 ? (
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
<p className="text-muted-foreground text-sm">
{todos.length === 0 ? 'Geen todos. Voeg er een toe hierboven.' : 'Geen todos voor deze selectie.'}
<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 — ST-510 */}
<div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center">
<p className="text-sm text-muted-foreground">
{mode === 'create'
? 'Nieuwe todo aanmaken — kaart wordt gebouwd in ST-510.'
: activeTodo
? `Geselecteerd: ${activeTodo.title}`
: 'Selecteer een rij of klik op + om te beginnen.'}
</p>
</div>
) : (
<>
<div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border">
{open.map(todo => (
<div key={todo.id} className="group flex items-center gap-3 px-4 py-3">
<input
type="checkbox"
checked={false}
onChange={() => handleToggle(todo.id, false)}
disabled={isDemo}
className="w-4 h-4 rounded accent-primary cursor-pointer"
/>
<span className="flex-1 text-sm">{todo.title}</span>
{todo.product_name && (
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
{todo.product_name}
</span>
)}
{!isDemo && (
<div className="opacity-0 group-hover:opacity-100 flex gap-2 shrink-0">
<button onClick={() => setPromotePbi(todo)} className="text-xs text-muted-foreground hover:text-foreground"> PBI</button>
<button onClick={() => setPromoteStory(todo)} className="text-xs text-muted-foreground hover:text-foreground"> Story</button>
</div>
)}
</div>
))}
{done.map(todo => (
<div key={todo.id} className="flex items-center gap-3 px-4 py-3 opacity-60">
<input
type="checkbox"
checked={true}
onChange={() => handleToggle(todo.id, true)}
disabled={isDemo}
className="w-4 h-4 rounded accent-primary cursor-pointer"
/>
<span className="flex-1 text-sm line-through text-muted-foreground">{todo.title}</span>
{todo.product_name && (
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
{todo.product_name}
</span>
)}
</div>
))}
</div>
{done.length > 0 && !isDemo && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs" onClick={handleArchive}>
Archiveer afgeronde items ({done.length})
</Button>
</div>
)}
</>
)}
{promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />

38
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "scrum4me",
"version": "0.3.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scrum4me",
"version": "0.3.0",
"version": "0.3.1",
"hasInstallScript": true,
"dependencies": {
"@base-ui/react": "^1.4.1",
@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",
@ -3909,6 +3910,26 @@
"tailwindcss": "4.2.4"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
@ -3927,6 +3948,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",

View file

@ -21,6 +21,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1",
"bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1",