Scrum4Me/components/todos/todo-list.tsx
Madhura68 5dc8033b85 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>
2026-04-25 19:55:36 +02:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
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 { 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
title: string
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}
/>
)
}
// --- 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 className="text-sm font-medium">Titel</label>
<Input name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
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 className="text-sm font-medium">Prioriteit</label>
<select 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 className="text-sm font-medium">Titel</label>
<Input name="title" defaultValue={todo.title} required />
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Product</label>
{products.length === 0 ? (
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
) : (
<select
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 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 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 className="text-sm font-medium">Prioriteit</label>
<select 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>
)
}
// --- 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 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
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)
}
})
}
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 — 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)} />
)}
{promoteStory && (
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
)}
</div>
)
}