feat: ST-501-ST-506 M5 todo-lijst en rolbeheer

- Todo-lijst met snelle invoer via Enter (ST-501)
- Todo afvinken met visuele doorstreping (ST-502)
- Archiveer afgeronde todos (ST-503)
- Promoveer todo naar PBI met product en prioriteit keuze (ST-504)
- Promoveer todo naar story met product, PBI en prioriteit keuze (ST-505)
- Rolbeheer in instellingen: Product Owner, Scrum Master, Developer (ST-506)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-24 11:59:25 +02:00
parent b71a1a7328
commit 8bb8754d01
5 changed files with 568 additions and 0 deletions

View file

@ -0,0 +1,70 @@
'use client'
import { useState, useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { updateRolesAction } from '@/actions/todos'
const ALL_ROLES = [
{ value: 'PRODUCT_OWNER', label: 'Product Owner' },
{ value: 'SCRUM_MASTER', label: 'Scrum Master' },
{ value: 'DEVELOPER', label: 'Developer' },
]
interface RoleManagerProps {
currentRoles: string[]
isDemo: boolean
}
export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) {
const [selected, setSelected] = useState<Set<string>>(new Set(currentRoles))
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [, startTransition] = useTransition()
function toggle(role: string) {
setSelected(prev => {
const next = new Set(prev)
next.has(role) ? next.delete(role) : next.add(role)
return next
})
setSaved(false)
setError(null)
}
function handleSave() {
if (selected.size === 0) {
setError('Minimaal één rol is verplicht')
return
}
startTransition(async () => {
const result = await updateRolesAction([...selected])
if (result.success) setSaved(true)
else setError(result.error ?? 'Opslaan mislukt')
})
}
return (
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
<h2 className="text-sm font-medium text-foreground">Mijn rollen</h2>
<div className="flex flex-wrap gap-3">
{ALL_ROLES.map(role => (
<label key={role.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selected.has(role.value)}
onChange={() => toggle(role.value)}
disabled={isDemo}
className="w-4 h-4 rounded accent-primary"
/>
<span className="text-sm">{role.label}</span>
</label>
))}
</div>
{error && <p className="text-xs text-error">{error}</p>}
{saved && <p className="text-xs text-success">Rollen opgeslagen.</p>}
{!isDemo && (
<Button size="sm" onClick={handleSave}>Opslaan</Button>
)}
</div>
)
}

View file

@ -0,0 +1,286 @@
'use client'
import { useState, useTransition, useActionState, useEffect, useRef } from 'react'
import { useFormStatus } from 'react-dom'
import { Button } from '@/components/ui/button'
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"
/>
<QuickSubmitButton isDemo={isDemo} />
</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 [state, formAction] = useActionState(
async (_prev: unknown, fd: FormData) => {
const result = await promoteTodoToPbiAction(_prev, fd)
if (result?.success) 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 [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) 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 () => {
await archiveCompletedTodosAction()
})
}
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>
)
}