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:
parent
b71a1a7328
commit
8bb8754d01
5 changed files with 568 additions and 0 deletions
70
components/settings/role-manager.tsx
Normal file
70
components/settings/role-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
286
components/todos/todo-list.tsx
Normal file
286
components/todos/todo-list.tsx
Normal 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'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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue