295 lines
11 KiB
TypeScript
295 lines
11 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
|
|
}
|
|
|
|
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"
|
|
/>
|
|
<DemoTooltip show={isDemo}>
|
|
<QuickSubmitButton isDemo={isDemo} />
|
|
</DemoTooltip>
|
|
</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 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 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 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(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" 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 () => {
|
|
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 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>
|
|
)
|
|
}
|