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

166
actions/todos.ts Normal file
View file

@ -0,0 +1,166 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function createTodoAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
const title = (formData.get('title') as string)?.trim()
if (!title) return { error: 'Titel is verplicht' }
await prisma.todo.create({ data: { user_id: session.userId, title } })
revalidatePath('/todos')
return { success: true }
}
export async function toggleTodoAction(id: string, done: boolean) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
const todo = await prisma.todo.findFirst({ where: { id, user_id: session.userId } })
if (!todo) return { error: 'Todo niet gevonden' }
await prisma.todo.update({ where: { id }, data: { done } })
revalidatePath('/todos')
return { success: true }
}
export async function archiveCompletedTodosAction() {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
await prisma.todo.updateMany({
where: { user_id: session.userId, done: true, archived: false },
data: { archived: true },
})
revalidatePath('/todos')
return { success: true }
}
const promotePbiSchema = z.object({
todoId: z.string(),
productId: z.string(),
title: z.string().min(1).max(200),
priority: z.coerce.number().int().min(1).max(4),
})
export async function promoteTodoToPbiAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = promotePbiSchema.safeParse({
todoId: formData.get('todoId'),
productId: formData.get('productId'),
title: formData.get('title'),
priority: formData.get('priority'),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
const product = await prisma.product.findFirst({
where: { id: parsed.data.productId, user_id: session.userId },
})
if (!product) return { error: 'Product niet gevonden' }
const last = await prisma.pbi.findFirst({
where: { product_id: parsed.data.productId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
})
await prisma.$transaction([
prisma.pbi.create({
data: {
product_id: parsed.data.productId,
title: parsed.data.title,
priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
},
}),
prisma.todo.delete({ where: { id: parsed.data.todoId } }),
])
revalidatePath('/todos')
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true }
}
const promoteStorySchema = z.object({
todoId: z.string(),
productId: z.string(),
pbiId: z.string(),
title: z.string().min(1).max(200),
priority: z.coerce.number().int().min(1).max(4),
})
export async function promoteTodoToStoryAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = promoteStorySchema.safeParse({
todoId: formData.get('todoId'),
productId: formData.get('productId'),
pbiId: formData.get('pbiId'),
title: formData.get('title'),
priority: formData.get('priority'),
})
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }
const pbi = await prisma.pbi.findFirst({
where: { id: parsed.data.pbiId, product: { user_id: session.userId } },
})
if (!pbi) return { error: 'PBI niet gevonden' }
const last = await prisma.story.findFirst({
where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority },
orderBy: { sort_order: 'desc' },
})
await prisma.$transaction([
prisma.story.create({
data: {
pbi_id: parsed.data.pbiId,
product_id: parsed.data.productId,
title: parsed.data.title,
priority: parsed.data.priority,
sort_order: (last?.sort_order ?? 0) + 1.0,
status: 'OPEN',
},
}),
prisma.todo.delete({ where: { id: parsed.data.todoId } }),
])
revalidatePath('/todos')
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true }
}
export async function updateRolesAction(roles: string[]) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER']
const filtered = roles.filter(r => validRoles.includes(r))
if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' }
await prisma.$transaction([
prisma.userRole.deleteMany({ where: { user_id: session.userId } }),
prisma.userRole.createMany({
data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })),
}),
])
revalidatePath('/settings')
return { success: true }
}

View file

@ -1,11 +1,18 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { RoleManager } from '@/components/settings/role-manager'
import Link from 'next/link'
export default async function SettingsPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const userRoles = await prisma.userRole.findMany({
where: { user_id: session.userId },
})
const currentRoles = userRoles.map(r => r.role as string)
return (
<div className="p-6 max-w-2xl mx-auto w-full space-y-6">
<h1 className="text-xl font-medium text-foreground">Instellingen</h1>
@ -18,6 +25,8 @@ export default async function SettingsPage() {
</p>
</div>
<RoleManager currentRoles={currentRoles} isDemo={session.isDemo ?? false} />
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>

37
app/(app)/todos/page.tsx Normal file
View file

@ -0,0 +1,37 @@
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { TodoList } from '@/components/todos/todo-list'
export default async function TodosPage() {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const todos = await prisma.todo.findMany({
where: { user_id: session.userId, archived: false },
orderBy: { created_at: 'asc' },
})
const products = await prisma.product.findMany({
where: { user_id: session.userId, archived: false },
orderBy: { name: 'asc' },
include: {
pbis: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, title: true } },
},
})
return (
<div className="p-6 max-w-2xl mx-auto w-full">
<h1 className="text-xl font-medium text-foreground mb-6">Todo&apos;s</h1>
<TodoList
todos={todos.map(t => ({ id: t.id, title: t.title, done: t.done, created_at: t.created_at.toISOString() }))}
products={products.map(p => ({
id: p.id,
name: p.name,
pbis: p.pbis,
}))}
isDemo={session.isDemo ?? false}
/>
</div>
)
}

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>
)
}