feat(ST-510): add TodoCard — aanmaken, bewerken en promoveren

- Aanmaken (+ knop): product-dropdown erft huidige filter, autoFocus op titel
- Bewerken (rij-klik): laadt todo in kaart; velden: product, titel, done-toggle
- Promoveren: → PBI en → Story knoppen openen bestaande dialogs
- key op TodoCard dwingt remount bij ander geselecteerde rij zodat
  defaultValue-velden altijd de juiste todo tonen
- SaveButton via useFormStatus voor pending-state op submit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-25 20:00:57 +02:00
parent f1384a87c1
commit 7f57f2b36f

View file

@ -1,6 +1,8 @@
'use client'
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
useReactTable,
getCoreRowModel,
@ -18,11 +20,12 @@ 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 {
createTodoAction,
updateTodoAction,
archiveSelectedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
} from '@/actions/todos'
import { useActionState } from 'react'
interface Todo {
id: string
@ -70,6 +73,15 @@ function IndeterminateCheckbox({
)
}
function SaveButton() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="sm" disabled={pending}>
{pending ? '…' : 'Opslaan'}
</Button>
)
}
// --- Promote to PBI dialog ---
function PromotePbiDialog({
todo,
@ -217,6 +229,141 @@ function PromoteStoryDialog({
)
}
// --- Detail card ---
function TodoCard({
mode,
activeTodo,
products,
isDemo,
defaultProductId,
onSuccess,
onPromotePbi,
onPromoteStory,
}: {
mode: 'idle' | 'create' | 'edit'
activeTodo: Todo | null
products: Product[]
isDemo: boolean
defaultProductId: string
onSuccess: () => void
onPromotePbi: (todo: Todo) => void
onPromoteStory: (todo: Todo) => void
}) {
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
useEffect(() => {
if (createState?.success) onSuccess()
}, [createState, onSuccess])
useEffect(() => {
if (editState?.success) onSuccess()
}, [editState, onSuccess])
if (mode === 'idle') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">Selecteer een rij of klik op + om te beginnen.</p>
</div>
)
}
if (mode === 'create') {
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Nieuwe todo</p>
<form action={createFormAction} className="space-y-3">
<div className="flex gap-3">
<select
name="productId"
defaultValue={defaultProductId}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
placeholder="Titel…"
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
{typeof createState?.error === 'string' && (
<p className="text-xs text-error">{createState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// Edit mode
if (!activeTodo) return null
return (
<div className="rounded-xl border border-border bg-surface-container-low p-5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Todo bewerken</p>
<form action={editFormAction} className="space-y-3">
<input type="hidden" name="id" value={activeTodo.id} />
<div className="flex gap-3">
<select
name="productId"
defaultValue={activeTodo.product_id ?? ''}
disabled={isDemo}
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
>
<option value="">Geen product</option>
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<Input
name="title"
defaultValue={activeTodo.title}
disabled={isDemo}
autoFocus
className="flex-1"
autoComplete="off"
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none">
<input
type="checkbox"
name="done"
defaultChecked={activeTodo.done}
disabled={isDemo}
className="size-4 accent-primary cursor-pointer"
/>
Afgerond
</label>
{typeof editState?.error === 'string' && (
<p className="text-xs text-error">{editState.error}</p>
)}
<div className="flex items-center gap-2">
{!isDemo && (
<>
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
PBI
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteStory(activeTodo)}>
Story
</Button>
</>
)}
<div className="flex-1" />
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
<SaveButton />
</div>
</form>
</div>
)
}
// --- Main component ---
export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [isPending, startTransition] = useTransition()
@ -307,7 +454,15 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
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
const cardMode = mode === 'create' ? 'create' : activeTodo ? 'edit' : 'idle'
const defaultProductId = selectedProductId !== 'all' ? selectedProductId : ''
const handleCancel = useCallback(() => {
setActiveRowId(null)
setMode('idle')
}, [])
function handleRowClick(todo: Todo) {
setActiveRowId(prev => prev === todo.id ? null : todo.id)
@ -331,6 +486,7 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
toast.success(`${n} todo${n === 1 ? '' : "'s"} gearchiveerd`)
setRowSelection({})
setActiveRowId(null)
setMode('idle')
}
})
}
@ -422,16 +578,18 @@ export function TodoList({ todos, products, isDemo }: TodoListProps) {
</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>
{/* Detail card */}
<TodoCard
key={activeRowId ?? (mode === 'create' ? 'create' : 'idle')}
mode={cardMode}
activeTodo={activeTodo}
products={products}
isDemo={isDemo}
defaultProductId={defaultProductId}
onSuccess={handleCancel}
onPromotePbi={setPromotePbi}
onPromoteStory={setPromoteStory}
/>
{promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />