ST-1241: Verwijder frontend todo-UI (page, component, navbar en marketing page) (#133)
* feat(cleanup): verwijder Todo's navlink en todo-referenties uit marketing page [cmotto5ia000nx3178lq6xk8d] - nav-bar.tsx: Todo's navLink verwijderd; Ideas-link blijft staan - app/page.tsx: /todos quick-access link, feature-entry en /api/todos API-doc verwijderd Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(cleanup): verwijder app/(app)/todos/ en components/todos/ [cmottjvzo000cx3172472cu4g] --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
11937d8a8d
commit
628fbd7e7b
5 changed files with 0 additions and 765 deletions
|
|
@ -1,19 +0,0 @@
|
|||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-6 max-w-2xl mx-auto w-full animate-pulse">
|
||||
<div className="h-6 w-20 bg-border rounded mb-6" />
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="h-8 w-32 bg-border/50 rounded-lg" />
|
||||
<div className="flex-1" />
|
||||
<div className="h-8 w-8 bg-border/50 rounded-lg" />
|
||||
</div>
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<div className="h-10 bg-border/30" />
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="h-12 border-t border-border bg-border/20" />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 h-24 bg-border/30 rounded-xl" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { productAccessFilter } from '@/lib/product-access'
|
||||
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' },
|
||||
include: { product: { select: { name: true } } },
|
||||
})
|
||||
|
||||
const products = await prisma.product.findMany({
|
||||
where: { ...productAccessFilter(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's</h1>
|
||||
<TodoList
|
||||
todos={todos.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description ?? null,
|
||||
done: t.done,
|
||||
created_at: t.created_at.toISOString(),
|
||||
product_id: t.product_id ?? null,
|
||||
product_name: t.product?.name ?? null,
|
||||
}))}
|
||||
products={products.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
pbis: p.pbis,
|
||||
}))}
|
||||
isDemo={session.isDemo ?? false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
app/page.tsx
11
app/page.tsx
|
|
@ -45,12 +45,6 @@ export default async function LandingPage() {
|
|||
>
|
||||
Solo
|
||||
</Link>
|
||||
<Link
|
||||
href="/todos"
|
||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||
>
|
||||
Todo's
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors"
|
||||
|
|
@ -366,10 +360,6 @@ export default async function LandingPage() {
|
|||
title: 'Async vraagkanaal',
|
||||
desc: 'Loopt een agent vast op een keuze? Hij plaatst een vraag via het bel-icoon. Jij beantwoordt hem wanneer het uitkomt; de agent pakt automatisch de draad weer op. Tijdens een Grill stelt Claude vragen via hetzelfde kanaal — antwoorden komen direct terug in de Idea-timeline.',
|
||||
},
|
||||
{
|
||||
title: "Todo's",
|
||||
desc: 'Lichtgewicht notities los van de sprint-hiërarchie. Filter, sorteer en archiveer via een tabel-weergave — handig voor invallen die nog geen story zijn.',
|
||||
},
|
||||
].map(({ title, desc }) => (
|
||||
<div key={title} className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2">
|
||||
<div className="text-sm font-medium text-primary">{title}</div>
|
||||
|
|
@ -639,7 +629,6 @@ curl -H "Authorization: Bearer $TOKEN" \\
|
|||
{ method: 'PATCH', path: '/api/stories/:id/tasks/reorder', desc: 'Taakvolgorde aanpassen (body: { task_ids: string[] })' },
|
||||
{ method: 'POST', path: '/api/stories/:id/log', desc: 'Activiteit vastleggen: implementatieplan, testresultaat of commit' },
|
||||
{ method: 'PATCH', path: '/api/tasks/:id', desc: 'Taakstatus of implementatieplan bijwerken' },
|
||||
{ method: 'POST', path: '/api/todos', desc: 'Todo aanmaken (body: { title, product_id })' },
|
||||
].map(({ method, path, desc }) => (
|
||||
<div key={path} className="flex items-start gap-3 bg-background border border-border rounded-lg px-4 py-3">
|
||||
<span className={`shrink-0 text-xs font-mono font-semibold px-2 py-0.5 rounded ${
|
||||
|
|
|
|||
|
|
@ -143,7 +143,6 @@ export function NavBar({
|
|||
: disabledSpan('Solo')}
|
||||
{navLink('/insights', 'Insights', pathname.startsWith('/insights'))}
|
||||
{navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))}
|
||||
{navLink('/todos', "Todo's", pathname.startsWith('/todos'))}
|
||||
{roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
|
||||
</nav>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,687 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { useFormStatus } from 'react-dom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type RowSelectionState,
|
||||
type PaginationState,
|
||||
} from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import {
|
||||
createTodoAction,
|
||||
updateTodoAction,
|
||||
archiveSelectedTodosAction,
|
||||
promoteTodoToPbiAction,
|
||||
promoteTodoToStoryAction,
|
||||
promoteTodoToIdeaAction,
|
||||
} from '@/actions/todos'
|
||||
|
||||
interface Todo {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
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
|
||||
}
|
||||
|
||||
// Checkbox with indeterminate support for TanStack row selection
|
||||
function IndeterminateCheckbox({
|
||||
indeterminate,
|
||||
className,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) {
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (ref.current) ref.current.indeterminate = indeterminate ?? false
|
||||
}, [indeterminate])
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
className={cn('size-4 cursor-pointer accent-primary', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SaveButton() {
|
||||
const { pending } = useFormStatus()
|
||||
return (
|
||||
<Button type="submit" size="sm" disabled={pending}>
|
||||
{pending ? '…' : 'Opslaan'}
|
||||
</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 htmlFor="promote-pbi-title" className="text-sm font-medium">Titel</label>
|
||||
<Input id="promote-pbi-title" name="title" defaultValue={todo.title} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="promote-pbi-product" className="text-sm font-medium">Product</label>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
|
||||
) : (
|
||||
<select
|
||||
id="promote-pbi-product"
|
||||
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 htmlFor="promote-pbi-priority" className="text-sm font-medium">Prioriteit</label>
|
||||
<select id="promote-pbi-priority" 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 htmlFor="promote-story-title" className="text-sm font-medium">Titel</label>
|
||||
<Input id="promote-story-title" name="title" defaultValue={todo.title} required />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="promote-story-product" className="text-sm font-medium">Product</label>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Maak eerst een product aan.</p>
|
||||
) : (
|
||||
<select
|
||||
id="promote-story-product"
|
||||
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 htmlFor="promote-story-pbi" 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 id="promote-story-pbi" 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 htmlFor="promote-story-priority" className="text-sm font-medium">Prioriteit</label>
|
||||
<select id="promote-story-priority" 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>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Promote to Idea dialog (M12 T-514) ---
|
||||
// Geen extra inputs nodig — title/description komen uit de todo, en
|
||||
// promoteTodoToIdeaAction archiveert de todo automatisch.
|
||||
function PromoteIdeaDialog({
|
||||
todo,
|
||||
onClose,
|
||||
}: { todo: Todo; onClose: () => void }) {
|
||||
const router = useRouter()
|
||||
const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose])
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [handleKey])
|
||||
|
||||
const [pending, startTransition] = useTransition()
|
||||
|
||||
function handleConfirm() {
|
||||
startTransition(async () => {
|
||||
const r = await promoteTodoToIdeaAction(todo.id)
|
||||
if ('error' in r) {
|
||||
toast.error(r.error)
|
||||
return
|
||||
}
|
||||
toast.success(`Idee aangemaakt (${r.idea_code})`)
|
||||
onClose()
|
||||
router.push(`/ideas/${r.idea_id}`)
|
||||
})
|
||||
}
|
||||
|
||||
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 Idee</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Maak een nieuw idee van ‘<strong>{todo.title}</strong>’. De Todo wordt
|
||||
gearchiveerd; je kunt hem later terugvinden in de archief-filter.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Het idee start als <code>DRAFT</code>. Je kunt het daarna grillen, plannen, en
|
||||
materialiseren tot een PBI.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||
Annuleren
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} disabled={pending}>
|
||||
{pending ? 'Bezig…' : 'Promoveren'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Detail card ---
|
||||
function TodoCard({
|
||||
mode,
|
||||
activeTodo,
|
||||
products,
|
||||
isDemo,
|
||||
defaultProductId,
|
||||
onSuccess,
|
||||
onPromotePbi,
|
||||
onPromoteStory,
|
||||
onPromoteIdea,
|
||||
}: {
|
||||
mode: 'idle' | 'create' | 'edit'
|
||||
activeTodo: Todo | null
|
||||
products: Product[]
|
||||
isDemo: boolean
|
||||
defaultProductId: string
|
||||
onSuccess: () => void
|
||||
onPromotePbi: (todo: Todo) => void
|
||||
onPromoteStory: (todo: Todo) => void
|
||||
onPromoteIdea: (todo: Todo) => void
|
||||
}) {
|
||||
const [createState, createFormAction] = useActionState(createTodoAction, undefined)
|
||||
const [editState, editFormAction] = useActionState(updateTodoAction, undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (createState && 'success' in createState && createState.success) onSuccess()
|
||||
}, [createState, onSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (editState && 'success' in editState && editState.success) onSuccess()
|
||||
}, [editState, onSuccess])
|
||||
|
||||
if (mode === 'idle') {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">Selecteer een rij of klik op + om te beginnen.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'create') {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-container-low p-5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Nieuwe todo</p>
|
||||
<form action={createFormAction} className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
name="productId"
|
||||
defaultValue={defaultProductId}
|
||||
disabled={isDemo}
|
||||
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
|
||||
>
|
||||
<option value="">Geen product</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<Input
|
||||
name="title"
|
||||
placeholder="Titel…"
|
||||
disabled={isDemo}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
name="description"
|
||||
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
|
||||
disabled={isDemo}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
/>
|
||||
{typeof createState?.error === 'string' && (
|
||||
<p className="text-xs text-error">{createState.error}</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
|
||||
<SaveButton />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit mode
|
||||
if (!activeTodo) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-container-low p-5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Todo bewerken</p>
|
||||
<form action={editFormAction} className="space-y-3">
|
||||
<input type="hidden" name="id" value={activeTodo.id} />
|
||||
<div className="flex gap-3">
|
||||
<select
|
||||
name="productId"
|
||||
defaultValue={activeTodo.product_id ?? ''}
|
||||
disabled={isDemo}
|
||||
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0"
|
||||
>
|
||||
<option value="">Geen product</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<Input
|
||||
name="title"
|
||||
defaultValue={activeTodo.title}
|
||||
disabled={isDemo}
|
||||
autoFocus
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
name="description"
|
||||
defaultValue={activeTodo.description ?? ''}
|
||||
placeholder="Beschrijving (optioneel, max 2000 tekens)…"
|
||||
disabled={isDemo}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="done"
|
||||
defaultChecked={activeTodo.done}
|
||||
disabled={isDemo}
|
||||
className="size-4 accent-primary cursor-pointer"
|
||||
/>
|
||||
Afgerond
|
||||
</label>
|
||||
{typeof editState?.error === 'string' && (
|
||||
<p className="text-xs text-error">{editState.error}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!isDemo && (
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteIdea(activeTodo)}>
|
||||
→ Idee
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}>
|
||||
→ PBI
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPromoteStory(activeTodo)}>
|
||||
→ Story
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button>
|
||||
<SaveButton />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main component ---
|
||||
export function TodoList({ todos, products, isDemo }: TodoListProps) {
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [selectedProductId, setSelectedProductId] = useState('all')
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
|
||||
const [activeRowId, setActiveRowId] = useState<string | null>(null)
|
||||
const [mode, setMode] = useState<'idle' | 'create'>('idle')
|
||||
const [promotePbi, setPromotePbi] = useState<Todo | null>(null)
|
||||
const [promoteStory, setPromoteStory] = useState<Todo | null>(null)
|
||||
const [promoteIdea, setPromoteIdea] = useState<Todo | null>(null)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (selectedProductId === 'all') return todos
|
||||
if (selectedProductId === '') return todos.filter(t => t.product_id === null)
|
||||
return todos.filter(t => t.product_id === selectedProductId)
|
||||
}, [todos, selectedProductId])
|
||||
|
||||
useEffect(() => {
|
||||
setPagination(p => ({ ...p, pageIndex: 0 }))
|
||||
setRowSelection({})
|
||||
}, [selectedProductId])
|
||||
|
||||
const columns = useMemo<ColumnDef<Todo>[]>(() => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<IndeterminateCheckbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
|
||||
onChange={e => table.toggleAllPageRowsSelected(e.target.checked)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<IndeterminateCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
onChange={e => row.toggleSelected(e.target.checked)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: 'Titel',
|
||||
cell: ({ row }) => (
|
||||
<p className={cn(
|
||||
'line-clamp-2 text-sm leading-snug',
|
||||
row.original.done && 'line-through text-muted-foreground'
|
||||
)}>
|
||||
{row.original.title}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'product_name',
|
||||
header: 'Product',
|
||||
cell: ({ row }) => row.original.product_name ? (
|
||||
<Badge variant="outline" className="text-xs font-normal">{row.original.product_name}</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: 'Datum',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{new Date(row.original.created_at).toLocaleDateString('nl-NL')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
], [])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filtered,
|
||||
columns,
|
||||
state: { rowSelection, pagination },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
})
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const { pageIndex, pageSize } = table.getState().pagination
|
||||
const totalRows = filtered.length
|
||||
const start = totalRows === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const end = Math.min((pageIndex + 1) * pageSize, totalRows)
|
||||
|
||||
const activeTodo = todos.find(t => t.id === activeRowId) ?? null
|
||||
const cardMode = mode === 'create' ? 'create' : activeTodo ? 'edit' : 'idle'
|
||||
const defaultProductId = selectedProductId !== 'all' ? selectedProductId : ''
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setActiveRowId(null)
|
||||
setMode('idle')
|
||||
}, [])
|
||||
|
||||
function handleRowClick(todo: Todo) {
|
||||
setActiveRowId(prev => prev === todo.id ? null : todo.id)
|
||||
setMode('idle')
|
||||
}
|
||||
|
||||
function handleNew() {
|
||||
setActiveRowId(null)
|
||||
setRowSelection({})
|
||||
setMode('create')
|
||||
}
|
||||
|
||||
function handleBulkArchive() {
|
||||
const ids = selectedRows.map(r => r.original.id)
|
||||
startTransition(async () => {
|
||||
const result = await archiveSelectedTodosAction(ids)
|
||||
if (result?.error) {
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Archiveren mislukt')
|
||||
} else {
|
||||
const n = ids.length
|
||||
toast.success(`${n} todo${n === 1 ? '' : "'s"} gearchiveerd`)
|
||||
setRowSelection({})
|
||||
setActiveRowId(null)
|
||||
setMode('idle')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={selectedProductId}
|
||||
onChange={e => setSelectedProductId(e.target.value)}
|
||||
className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"
|
||||
>
|
||||
<option value="all">Alles</option>
|
||||
<option value="">Geen product</option>
|
||||
{products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{selectedCount > 0 && !isDemo && (
|
||||
<Button variant="outline" size="sm" onClick={handleBulkArchive} disabled={isPending}>
|
||||
Archiveer geselecteerde ({selectedCount})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DemoTooltip show={isDemo}>
|
||||
<Button size="sm" onClick={handleNew} disabled={isDemo}>+</Button>
|
||||
</DemoTooltip>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(hg => (
|
||||
<TableRow key={hg.id} className="hover:bg-transparent">
|
||||
{hg.headers.map(header => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length > 0 ? (
|
||||
table.getRowModel().rows.map(row => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'cursor-pointer hover:bg-surface-container-low',
|
||||
activeRowId === row.original.id
|
||||
? 'bg-primary/5'
|
||||
: row.getIsSelected()
|
||||
? 'bg-primary/10'
|
||||
: ''
|
||||
)}
|
||||
onClick={() => handleRowClick(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="h-32 text-center text-sm text-muted-foreground">
|
||||
{todos.length === 0
|
||||
? "Nog geen todo's. Gebruik + om er een aan te maken."
|
||||
: "Geen todo's voor deze selectie."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalRows > pageSize && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{start}–{end} van {totalRows}</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>‹</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>›</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail card */}
|
||||
<TodoCard
|
||||
key={activeRowId ?? (mode === 'create' ? 'create' : 'idle')}
|
||||
mode={cardMode}
|
||||
activeTodo={activeTodo}
|
||||
products={products}
|
||||
isDemo={isDemo}
|
||||
defaultProductId={defaultProductId}
|
||||
onSuccess={handleCancel}
|
||||
onPromotePbi={setPromotePbi}
|
||||
onPromoteStory={setPromoteStory}
|
||||
onPromoteIdea={setPromoteIdea}
|
||||
/>
|
||||
|
||||
{promotePbi && (
|
||||
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
|
||||
)}
|
||||
{promoteStory && (
|
||||
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
|
||||
)}
|
||||
{promoteIdea && (
|
||||
<PromoteIdeaDialog todo={promoteIdea} onClose={() => setPromoteIdea(null)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue