De product-dropdown fungeert nu als filter voor de lijst: - 'Alles' (default) toont alle todo's - 'Geen product' toont alleen ongelinkte todo's - Een specifiek product toont alleen todo's van dat product Nieuw aangemaakte todo's krijgen het geselecteerde product mee. 'Alles' en 'Geen product' resulteren in een todo zonder productlink. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
359 lines
13 KiB
TypeScript
359 lines
13 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 {
|
||
createTodoAction,
|
||
toggleTodoAction,
|
||
archiveCompletedTodosAction,
|
||
promoteTodoToPbiAction,
|
||
promoteTodoToStoryAction,
|
||
} from '@/actions/todos'
|
||
|
||
interface Todo {
|
||
id: string
|
||
title: string
|
||
done: boolean
|
||
created_at: string
|
||
product_id: string | null
|
||
product_name: string | null
|
||
}
|
||
|
||
interface Pbi {
|
||
id: string
|
||
title: string
|
||
}
|
||
|
||
interface Product {
|
||
id: string
|
||
name: string
|
||
pbis: Pbi[]
|
||
}
|
||
|
||
interface TodoListProps {
|
||
todos: Todo[]
|
||
products: Product[]
|
||
isDemo: boolean
|
||
}
|
||
|
||
function QuickInput({
|
||
products,
|
||
isDemo,
|
||
selectedProductId,
|
||
onProductChange,
|
||
}: {
|
||
products: Product[]
|
||
isDemo: boolean
|
||
selectedProductId: string
|
||
onProductChange: (id: string) => void
|
||
}) {
|
||
const [state, formAction] = useActionState(createTodoAction, undefined)
|
||
const titleRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (state?.success && titleRef.current) titleRef.current.value = ''
|
||
}, [state])
|
||
|
||
return (
|
||
<div className="mb-6 space-y-1">
|
||
<form action={formAction} className="flex gap-2">
|
||
<select
|
||
name="productId"
|
||
value={selectedProductId}
|
||
onChange={e => onProductChange(e.target.value)}
|
||
disabled={isDemo}
|
||
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
|
||
>
|
||
<option value="all">Alles</option>
|
||
<option value="">Geen product</option>
|
||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||
</select>
|
||
<Input
|
||
ref={titleRef}
|
||
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>
|
||
{typeof state?.error === 'string' && (
|
||
<p className="text-xs text-error">{state.error}</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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
|
||
defaultValue={todo.product_id ?? products[0]?.id}
|
||
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">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(todo.product_id ?? 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">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 [selectedProductId, setSelectedProductId] = useState('all')
|
||
|
||
const filtered =
|
||
selectedProductId === 'all'
|
||
? todos
|
||
: selectedProductId === ''
|
||
? todos.filter(t => t.product_id === null)
|
||
: todos.filter(t => t.product_id === selectedProductId)
|
||
|
||
const open = filtered.filter(t => !t.done)
|
||
const done = filtered.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
|
||
products={products}
|
||
isDemo={isDemo}
|
||
selectedProductId={selectedProductId}
|
||
onProductChange={setSelectedProductId}
|
||
/>
|
||
|
||
{filtered.length === 0 ? (
|
||
<div className="bg-surface-container-low border border-border rounded-xl p-12 text-center">
|
||
<p className="text-muted-foreground text-sm">
|
||
{todos.length === 0 ? 'Geen todo’s. Voeg er een toe hierboven.' : 'Geen todo’s voor deze selectie.'}
|
||
</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>
|
||
{todo.product_name && (
|
||
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
|
||
{todo.product_name}
|
||
</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>
|
||
{todo.product_name && (
|
||
<span className="text-xs text-muted-foreground bg-surface-container px-1.5 py-0.5 rounded shrink-0">
|
||
{todo.product_name}
|
||
</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>
|
||
)
|
||
}
|