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:
parent
6fa768aabe
commit
5dc8033b85
3 changed files with 268 additions and 148 deletions
|
|
@ -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"
|
||||
/>
|
||||
<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>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className={cn('size-4 cursor-pointer accent-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -247,107 +217,222 @@ 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 todo’s. Voeg er een toe hierboven.' : 'Geen todo’s voor deze selectie.'}
|
||||
</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 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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
</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 && (
|
||||
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
|
||||
)}
|
||||
|
|
|
|||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue