621 lines
22 KiB
TypeScript
621 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react'
|
||
import { useActionState } from 'react'
|
||
import { useFormStatus } from 'react-dom'
|
||
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,
|
||
} 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>
|
||
)
|
||
}
|
||
|
||
// --- Detail card ---
|
||
function TodoCard({
|
||
mode,
|
||
activeTodo,
|
||
products,
|
||
isDemo,
|
||
defaultProductId,
|
||
onSuccess,
|
||
onPromotePbi,
|
||
onPromoteStory,
|
||
}: {
|
||
mode: 'idle' | 'create' | 'edit'
|
||
activeTodo: Todo | null
|
||
products: Product[]
|
||
isDemo: boolean
|
||
defaultProductId: string
|
||
onSuccess: () => void
|
||
onPromotePbi: (todo: Todo) => void
|
||
onPromoteStory: (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={() => 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 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}
|
||
/>
|
||
|
||
{promotePbi && (
|
||
<PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} />
|
||
)}
|
||
{promoteStory && (
|
||
<PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|