Scrum4Me/components/todos/todo-list.tsx
janpeter visser d11b114fc1 feat: ST-601-ST-612 M6 polish, beveiliging en launch-ready
- ST-601/602: loading skeletons en error boundary
- ST-603: Sonner toasts op alle CRUD-operaties
- ST-604: DemoTooltip op uitgeschakelde knoppen
- ST-605: KeyboardSensor dnd-kit, Escape sluit modals
- ST-606: min-width banner < 1024px
- ST-607: WCAG AA aria-labels en skip link
- ST-608: rate limiting login (10/min) en registratie (5/uur)
- ST-609: security integratietests cross-user toegang (7 tests)
- ST-610: GitHub Actions CI/CD workflow
- ST-611: README met quickstart, deployment en API-docs
- ST-612: Lars-flow acceptatiechecklist
- fix: settings toont gebruikersnaam i.p.v. interne id
- fix: seed idempotent, testdata altijd gekoppeld aan demo-gebruiker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 12:36:23 +02:00

298 lines
12 KiB
TypeScript

'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 { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import {
createTodoAction,
toggleTodoAction,
archiveCompletedTodosAction,
promoteTodoToPbiAction,
promoteTodoToStoryAction,
} from '@/actions/todos'
import { cn } from '@/lib/utils'
interface Todo {
id: string
title: string
done: boolean
created_at: string
}
interface Pbi {
id: string
title: string
}
interface Product {
id: string
name: string
pbis: Pbi[]
}
interface TodoListProps {
todos: Todo[]
products: Product[]
isDemo: boolean
}
function QuickInput({ isDemo }: { isDemo: boolean }) {
const [, formAction] = useActionState(createTodoAction, undefined)
const ref = useRef<HTMLFormElement>(null)
return (
<form
ref={ref}
action={formAction}
onSubmit={() => setTimeout(() => ref.current?.reset(), 0)}
className="flex gap-2 mb-6"
>
<Input
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>
)
}
function QuickSubmitButton({ isDemo }: { isDemo: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending || isDemo}>
{pending ? '…' : 'Toevoegen'}
</Button>
)
}
// --- 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 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" selected>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(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" selected>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 list ---
export function TodoList({ todos, products, isDemo }: TodoListProps) {
const [, startTransition] = useTransition()
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
const open = todos.filter(t => !t.done)
const done = todos.filter(t => t.done)
function handleToggle(id: string, current: boolean) {
startTransition(async () => {
await toggleTodoAction(id, !current)
})
}
function handleArchive() {
startTransition(async () => {
const result = await archiveCompletedTodosAction()
if (result && 'error' in result) toast.error(result.error ?? 'Archiveren mislukt')
else toast.success('Afgeronde todos gearchiveerd')
})
}
return (
<div className="space-y-4">
<QuickInput isDemo={isDemo} />
{todos.length === 0 ? (
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
<p className="text-muted-foreground text-sm">Geen todo&apos;s. Voeg er een toe hierboven.</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>
{!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>
</div>
))}
</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>
)}
</>
)}
{promotePbi && (
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
)}
{promoteStory && (
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
)}
</div>
)
}