diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx index b22e146..398f5b0 100644 --- a/components/todos/todo-list.tsx +++ b/components/todos/todo-list.tsx @@ -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(null) - +// Checkbox with indeterminate support for TanStack row selection +function IndeterminateCheckbox({ + indeterminate, + className, + ...props +}: React.InputHTMLAttributes & { indeterminate?: boolean }) { + const ref = useRef(null) useEffect(() => { - if (state?.success && titleRef.current) titleRef.current.value = '' - }, [state]) - + if (ref.current) ref.current.indeterminate = indeterminate ?? false + }, [indeterminate]) return ( -
-
- - - - - -
- {typeof state?.error === 'string' && ( -

{state.error}

- )} -
- ) -} - -function QuickSubmitButton({ isDemo }: { isDemo: boolean }) { - const { pending } = useFormStatus() - return ( - + ) } @@ -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({}) + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }) + const [activeRowId, setActiveRowId] = useState(null) + const [mode, setMode] = useState<'idle' | 'create'>('idle') const [promotePbi, setPromotePbi] = useState(null) const [promoteStory, setPromoteStory] = useState(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[]>(() => [ + { + id: 'select', + header: ({ table }) => ( + table.toggleAllPageRowsSelected(e.target.checked)} + /> + ), + cell: ({ row }) => ( + row.toggleSelected(e.target.checked)} + onClick={e => e.stopPropagation()} + /> + ), + }, + { + accessorKey: 'title', + header: 'Titel', + cell: ({ row }) => ( +

+ {row.original.title} +

+ ), + }, + { + accessorKey: 'product_name', + header: 'Product', + cell: ({ row }) => row.original.product_name ? ( + {row.original.product_name} + ) : ( + + ), + }, + { + accessorKey: 'created_at', + header: 'Datum', + cell: ({ row }) => ( + + {new Date(row.original.created_at).toLocaleDateString('nl-NL')} + + ), + }, + ], []) + + 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 (
- + {/* Toolbar */} +
+ - {filtered.length === 0 ? ( -
-

- {todos.length === 0 ? 'Geen todo’s. Voeg er een toe hierboven.' : 'Geen todo’s voor deze selectie.'} -

-
- ) : ( - <> -
- {open.map(todo => ( -
- handleToggle(todo.id, false)} - disabled={isDemo} - className="w-4 h-4 rounded accent-primary cursor-pointer" - /> - {todo.title} - {todo.product_name && ( - - {todo.product_name} - - )} - {!isDemo && ( -
- - -
- )} -
- ))} - {done.map(todo => ( -
- handleToggle(todo.id, true)} - disabled={isDemo} - className="w-4 h-4 rounded accent-primary cursor-pointer" - /> - {todo.title} - {todo.product_name && ( - - {todo.product_name} - - )} -
+
+ + {selectedCount > 0 && !isDemo && ( + + )} + + + + +
+ + {/* Table */} +
+ + + {table.getHeaderGroups().map(hg => ( + + {hg.headers.map(header => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + ))} + + + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map(row => ( + handleRowClick(row.original)} + > + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {todos.length === 0 + ? "Nog geen todo's. Gebruik + om er een aan te maken." + : "Geen todo's voor deze selectie."} + + + )} + +
+
+ + {/* Pagination */} + {totalRows > pageSize && ( +
+ {start}–{end} van {totalRows} +
+ +
- - {done.length > 0 && !isDemo && ( -
- -
- )} - +
)} + {/* Detail card — ST-510 */} +
+

+ {mode === 'create' + ? 'Nieuwe todo aanmaken — kaart wordt gebouwd in ST-510.' + : activeTodo + ? `Geselecteerd: ${activeTodo.title}` + : 'Selecteer een rij of klik op + om te beginnen.'} +

+
+ {promotePbi && ( setPromotePbi(null)} /> )} diff --git a/package-lock.json b/package-lock.json index 3c29ceb..bb7cc0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 72bafb5..a889ecd 100644 --- a/package.json +++ b/package.json @@ -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",