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:
parent
f1384a87c1
commit
7f57f2b36f
1 changed files with 169 additions and 11 deletions
|
|
@ -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)} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue