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' 'use client'
import { useState, useTransition, useActionState, useEffect, useRef, useCallback } from 'react' import { useState, useTransition, useMemo, 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 { import {
createTodoAction, useReactTable,
toggleTodoAction, getCoreRowModel,
archiveCompletedTodosAction, 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, promoteTodoToPbiAction,
promoteTodoToStoryAction, promoteTodoToStoryAction,
} from '@/actions/todos' } from '@/actions/todos'
import { useActionState } from 'react'
interface Todo { interface Todo {
id: string id: string
@ -40,63 +50,23 @@ interface TodoListProps {
isDemo: boolean isDemo: boolean
} }
function QuickInput({ // Checkbox with indeterminate support for TanStack row selection
products, function IndeterminateCheckbox({
isDemo, indeterminate,
selectedProductId, className,
onProductChange, ...props
}: { }: React.InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) {
products: Product[] const ref = useRef<HTMLInputElement>(null)
isDemo: boolean
selectedProductId: string
onProductChange: (id: string) => void
}) {
const [state, formAction] = useActionState(createTodoAction, undefined)
const titleRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
if (state?.success && titleRef.current) titleRef.current.value = '' if (ref.current) ref.current.indeterminate = indeterminate ?? false
}, [state]) }, [indeterminate])
return ( return (
<div className="mb-6 space-y-1"> <input
<form action={formAction} className="flex gap-2"> ref={ref}
<select type="checkbox"
name="productId" className={cn('size-4 cursor-pointer accent-primary', className)}
value={selectedProductId} {...props}
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"
/>
<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,107 +217,222 @@ function PromoteStoryDialog({
) )
} }
// --- Main list --- // --- Main component ---
export function TodoList({ todos, products, isDemo }: TodoListProps) { 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 [promotePbi, setPromotePbi] = useState<Todo | null>(null)
const [promoteStory, setPromoteStory] = useState<Todo | null>(null) const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
const [selectedProductId, setSelectedProductId] = useState('all')
const filtered = const filtered = useMemo(() => {
selectedProductId === 'all' if (selectedProductId === 'all') return todos
? todos if (selectedProductId === '') return todos.filter(t => t.product_id === null)
: selectedProductId === '' return todos.filter(t => t.product_id === selectedProductId)
? todos.filter(t => t.product_id === null) }, [todos, selectedProductId])
: todos.filter(t => t.product_id === selectedProductId)
const open = filtered.filter(t => !t.done) useEffect(() => {
const done = filtered.filter(t => t.done) setPagination(p => ({ ...p, pageIndex: 0 }))
setRowSelection({})
}, [selectedProductId])
function handleToggle(id: string, current: boolean) { const columns = useMemo<ColumnDef<Todo>[]>(() => [
startTransition(async () => { {
await toggleTodoAction(id, !current) 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 () => { startTransition(async () => {
const result = await archiveCompletedTodosAction() const result = await archiveSelectedTodosAction(ids)
if (result && 'error' in result) toast.error(result.error ?? 'Archiveren mislukt') if (result?.error) {
else toast.success('Afgeronde todos gearchiveerd') 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<QuickInput {/* Toolbar */}
products={products} <div className="flex items-center gap-3 flex-wrap">
isDemo={isDemo} <select
selectedProductId={selectedProductId} value={selectedProductId}
onProductChange={setSelectedProductId} 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="flex-1" />
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
<p className="text-muted-foreground text-sm"> {selectedCount > 0 && !isDemo && (
{todos.length === 0 ? 'Geen todos. Voeg er een toe hierboven.' : 'Geen todos voor deze selectie.'} <Button variant="outline" size="sm" onClick={handleBulkArchive} disabled={isPending}>
</p> Archiveer geselecteerde ({selectedCount})
</div> </Button>
) : ( )}
<>
<div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border"> <DemoTooltip show={isDemo}>
{open.map(todo => ( <Button size="sm" onClick={handleNew} disabled={isDemo}>+</Button>
<div key={todo.id} className="group flex items-center gap-3 px-4 py-3"> </DemoTooltip>
<input </div>
type="checkbox"
checked={false} {/* Table */}
onChange={() => handleToggle(todo.id, false)} <div className="rounded-xl border border-border overflow-hidden">
disabled={isDemo} <Table>
className="w-4 h-4 rounded accent-primary cursor-pointer" <TableHeader>
/> {table.getHeaderGroups().map(hg => (
<span className="flex-1 text-sm">{todo.title}</span> <TableRow key={hg.id} className="hover:bg-transparent">
{todo.product_name && ( {hg.headers.map(header => (
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0"> <TableHead key={header.id}>
{todo.product_name} {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</span> </TableHead>
)} ))}
{!isDemo && ( </TableRow>
<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>
))} ))}
</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>
</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>
)}
</>
)} )}
{/* 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>
{promotePbi && ( {promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} /> <PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
)} )}

38
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "scrum4me", "name": "scrum4me",
"version": "0.3.0", "version": "0.3.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "scrum4me", "name": "scrum4me",
"version": "0.3.0", "version": "0.3.1",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.1", "@base-ui/react": "^1.4.1",
@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -3909,6 +3910,26 @@
"tailwindcss": "4.2.4" "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": { "node_modules/@tanstack/react-virtual": {
"version": "3.13.24", "version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", "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" "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": { "node_modules/@tanstack/virtual-core": {
"version": "3.14.0", "version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", "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", "@dnd-kit/utilities": "^3.2.2",
"@prisma/adapter-pg": "^7.8.0", "@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0", "@prisma/client": "^7.8.0",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",