From 8bb8754d018d81e0f7fc8f6416a852f565add8f2 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 24 Apr 2026 11:59:25 +0200 Subject: [PATCH] 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 --- actions/todos.ts | 166 ++++++++++++++++ app/(app)/settings/page.tsx | 9 + app/(app)/todos/page.tsx | 37 ++++ components/settings/role-manager.tsx | 70 +++++++ components/todos/todo-list.tsx | 286 +++++++++++++++++++++++++++ 5 files changed, 568 insertions(+) create mode 100644 actions/todos.ts create mode 100644 app/(app)/todos/page.tsx create mode 100644 components/settings/role-manager.tsx create mode 100644 components/todos/todo-list.tsx diff --git a/actions/todos.ts b/actions/todos.ts new file mode 100644 index 0000000..6a7be3f --- /dev/null +++ b/actions/todos.ts @@ -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(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 } +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 0bb283c..1fadf0f 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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(await cookies(), sessionOptions) + const userRoles = await prisma.userRole.findMany({ + where: { user_id: session.userId }, + }) + const currentRoles = userRoles.map(r => r.role as string) + return (

Instellingen

@@ -18,6 +25,8 @@ export default async function SettingsPage() {

+ +

API Tokens

diff --git a/app/(app)/todos/page.tsx b/app/(app)/todos/page.tsx new file mode 100644 index 0000000..9aebb39 --- /dev/null +++ b/app/(app)/todos/page.tsx @@ -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(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 ( +
+

Todo's

+ ({ 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} + /> +
+ ) +} diff --git a/components/settings/role-manager.tsx b/components/settings/role-manager.tsx new file mode 100644 index 0000000..0e0a090 --- /dev/null +++ b/components/settings/role-manager.tsx @@ -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>(new Set(currentRoles)) + const [error, setError] = useState(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 ( +
+

Mijn rollen

+
+ {ALL_ROLES.map(role => ( + + ))} +
+ {error &&

{error}

} + {saved &&

Rollen opgeslagen.

} + {!isDemo && ( + + )} +
+ ) +} diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx new file mode 100644 index 0000000..c4ec06c --- /dev/null +++ b/components/todos/todo-list.tsx @@ -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(null) + + return ( +
setTimeout(() => ref.current?.reset(), 0)} + className="flex gap-2 mb-6" + > + + + + ) +} + +function QuickSubmitButton({ isDemo }: { isDemo: boolean }) { + const { pending } = useFormStatus() + return ( + + ) +} + +// --- 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 ( +
+
+

Promoveer naar PBI

+

Let op: dit kan niet ongedaan worden gemaakt.

+
+ +
+ + +
+
+ + {products.length === 0 ? ( +

Maak eerst een product aan.

+ ) : ( + + )} +
+
+ + +
+ {typeof state?.error === 'string' &&

{state.error}

} +
+ + +
+
+
+
+ ) +} + +// --- 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 ( +
+
+

Promoveer naar Story

+

Let op: dit kan niet ongedaan worden gemaakt.

+
+ + +
+ + +
+
+ + {products.length === 0 ? ( +

Maak eerst een product aan.

+ ) : ( + + )} +
+
+ + {!selectedProduct?.pbis.length ? ( +

Maak eerst een PBI aan in dit product.

+ ) : ( + + )} +
+
+ + +
+ {typeof state?.error === 'string' &&

{state.error}

} +
+ + +
+
+
+
+ ) +} + +// --- Main list --- +export function TodoList({ todos, products, isDemo }: TodoListProps) { + const [, startTransition] = useTransition() + const [promotePbi, setPromotePbi] = useState(null) + const [promoteStory, setPromoteStory] = useState(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 ( +
+ + + {todos.length === 0 ? ( +
+

Geen todo's. Voeg er een toe hierboven.

+
+ ) : ( + <> +
+ {open.map(todo => ( +
+ handleToggle(todo.id, false)} + disabled={isDemo} + className="w-4 h-4 rounded accent-primary cursor-pointer" + /> + {todo.title} + {!isDemo && ( +
+ + +
+ )} +
+ ))} + {done.map(todo => ( +
+ handleToggle(todo.id, true)} + disabled={isDemo} + className="w-4 h-4 rounded accent-primary cursor-pointer" + /> + {todo.title} +
+ ))} +
+ + {done.length > 0 && !isDemo && ( +
+ +
+ )} + + )} + + {promotePbi && ( + setPromotePbi(null)} /> + )} + {promoteStory && ( + setPromoteStory(null)} /> + )} +
+ ) +}