Merge pull request 'feat/PBI-98-products-table' (#5) from feat/PBI-98-products-table into main
Some checks are pending
CI / Lint, Typecheck, Test & Build (push) Waiting to run
CI / Detect deploy-relevant changes (push) Blocked by required conditions
CI / Deploy Preview (PR) (push) Blocked by required conditions
CI / Deploy Production (main) (push) Blocked by required conditions
CI / Deploy Manual (workflow_dispatch) (push) Waiting to run

Reviewed-on: #5
This commit is contained in:
Janpeter Visser 2026-05-16 15:54:02 +02:00
commit 03eb1432ab
21 changed files with 1013 additions and 409 deletions

View file

@ -1,56 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }))
vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: vi.fn() }) }))
vi.mock('@/actions/products', () => ({ restoreProductAction: vi.fn() }))
vi.mock('@/actions/active-product', () => ({ setActiveProductAction: vi.fn() }))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('@/components/dialogs/product-dialog', () => ({
ProductDialog: ({ open }: { open: boolean }) => (open ? <div role="dialog">ProductDialog</div> : null),
}))
import { ProductList } from '@/components/dashboard/product-list'
const PRODUCT = {
id: 'p1',
name: 'Mijn Product',
code: 'MP',
description: 'Een product',
repo_url: 'https://github.com/foo/bar',
definition_of_done: 'klaar als het werkt',
auto_pr: false,
}
beforeEach(() => {
pushMock.mockClear()
})
describe('ProductList — edit-icoon (todo cmoq3ox51)', () => {
it('rendert pencil-icoon (Bewerk product) op active card, geen tekstknop "Bewerken"', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.getByLabelText('Bewerk product')).toBeTruthy()
// Oude tekstknop is weg
expect(screen.queryByText('Bewerken')).toBeNull()
})
it('opent ProductDialog op klik (en stopt propagation zodat card-click niet navigeert)', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />)
expect(screen.queryByRole('dialog')).toBeNull()
fireEvent.click(screen.getByLabelText('Bewerk product'))
expect(screen.getByRole('dialog')).toBeTruthy()
expect(pushMock).not.toHaveBeenCalled() // card-navigation niet getriggerd
})
it('demo-user: knop is disabled', () => {
render(<ProductList products={[PRODUCT]} isDemo={true} activeProductId="p1" />)
const btn = screen.getByLabelText('Bewerk product') as HTMLButtonElement
expect(btn.disabled).toBe(true)
})
it('toont geen edit-icoon bij gearchiveerde producten', () => {
render(<ProductList products={[PRODUCT]} isDemo={false} showArchived={true} activeProductId={null} />)
expect(screen.queryByLabelText('Bewerk product')).toBeNull()
})
})

View file

@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}))
vi.mock('@/actions/products', () => ({
archiveProductAction: vi.fn().mockResolvedValue({ success: true }),
restoreProductAction: vi.fn().mockResolvedValue({ success: true }),
deleteProductAction: vi.fn().mockResolvedValue({ success: true }),
}))
vi.mock('@/actions/active-product', () => ({
setActiveProductAction: vi.fn().mockResolvedValue({ success: true }),
}))
import { ProductRowActions } from '@/components/dashboard/product-row-actions'
const baseProps = {
productId: 'p-1',
productName: 'Scrum4Me',
isActive: false,
isArchived: false,
isDemo: false,
}
describe('ProductRowActions', () => {
it('toont Activeer-knop voor inactief, niet-archived product', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByText(/activeer/i)).not.toBeNull()
})
it('toont "Actief"-badge voor active product (geen Activeer-knop)', () => {
render(<ProductRowActions {...baseProps} isActive={true} />)
expect(screen.queryByText('Actief')).not.toBeNull()
expect(screen.queryByText(/^activeer$/i)).toBeNull()
})
it('verbergt Activeer-knop én badge bij archived product', () => {
render(<ProductRowActions {...baseProps} isArchived={true} />)
expect(screen.queryByText(/^activeer$/i)).toBeNull()
expect(screen.queryByText('Actief')).toBeNull()
})
it('rendert Docs-knop met aria-label', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByLabelText('Docs')).not.toBeNull()
})
it('rendert Open-backlog-knop met aria-label', () => {
render(<ProductRowActions {...baseProps} />)
expect(screen.queryByLabelText('Open backlog')).not.toBeNull()
})
it('Meer-acties-knop is disabled in demo-modus', () => {
render(<ProductRowActions {...baseProps} isDemo={true} />)
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
expect(more.disabled).toBe(true)
})
it('Meer-acties-knop is enabled voor reguliere user', () => {
render(<ProductRowActions {...baseProps} />)
const more = screen.getByLabelText('Meer acties') as HTMLButtonElement
expect(more.disabled).toBe(false)
})
})

View file

@ -0,0 +1,37 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
}))
vi.mock('@/components/dialogs/product-dialog', () => ({
ProductDialog: () => null,
}))
import { ProductsEmptyState } from '@/components/dashboard/products-empty-state'
describe('ProductsEmptyState', () => {
it('toont de empty-tekst', () => {
render(<ProductsEmptyState isDemo={false} />)
expect(screen.queryByText(/nog geen producten aangemaakt/i)).not.toBeNull()
})
it('toont NewProductButton voor reguliere user', () => {
render(<ProductsEmptyState isDemo={false} />)
expect(screen.queryByText(/nieuw product/i)).not.toBeNull()
})
it('toont GEEN NewProductButton in demo-modus', () => {
render(<ProductsEmptyState isDemo={true} />)
expect(screen.queryByText(/nieuw product/i)).toBeNull()
})
it('rendert in een gestylde container', () => {
const { container } = render(<ProductsEmptyState isDemo={false} />)
const root = container.firstChild as HTMLElement
expect(root.className).toContain('rounded-xl')
expect(root.className).toContain('border')
})
})

View file

@ -0,0 +1,96 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { SortHeader } from '@/components/shared/sort-header'
type Cols = 'name' | 'date'
describe('SortHeader', () => {
it('rendert label en is een button', () => {
render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="date"
sortDir="asc"
onSort={vi.fn()}
/>,
)
expect(screen.queryByText('Naam')).not.toBeNull()
expect(screen.getByRole('button')).not.toBeNull()
})
it('roept onSort aan met juiste col bij klik', () => {
const onSort = vi.fn()
render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="date"
sortDir="asc"
onSort={onSort}
/>,
)
fireEvent.click(screen.getByRole('button'))
expect(onSort).toHaveBeenCalledWith('name')
})
it('actieve kolom heeft text-foreground class', () => {
render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="name"
sortDir="asc"
onSort={vi.fn()}
/>,
)
const button = screen.getByRole('button')
expect(button.className).toContain('text-foreground')
expect(button.className).not.toContain('text-muted-foreground')
})
it('niet-actieve kolom heeft text-muted-foreground class', () => {
render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="date"
sortDir="asc"
onSort={vi.fn()}
/>,
)
const button = screen.getByRole('button')
expect(button.className).toContain('text-muted-foreground')
})
it('accepteert custom className', () => {
render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="date"
sortDir="asc"
onSort={vi.fn()}
className="ml-4 custom-class"
/>,
)
const button = screen.getByRole('button')
expect(button.className).toContain('custom-class')
expect(button.className).toContain('ml-4')
})
it('rendert een svg-icoon naast het label', () => {
const { container } = render(
<SortHeader<Cols>
col="name"
label="Naam"
sortKey="name"
sortDir="asc"
onSort={vi.fn()}
/>,
)
expect(container.querySelector('svg')).not.toBeNull()
})
})

View file

@ -306,6 +306,33 @@ export async function restoreProductAction(id: string) {
return { success: true }
}
// PBI-98 / T-1089: owner-only product-delete vanuit de dashboard-tabel.
// Cascade-delete wordt afgehandeld door Prisma (onDelete: Cascade op PBI,
// Story, Task, ClaudeJob, ProductDoc etc.). Bestaande active_product_id
// referenties worden eerst genulleerd.
export async function deleteProductAction(id: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const product = await prisma.product.findFirst({
where: { id, user_id: session.userId },
select: { id: true },
})
if (!product) return { error: 'Product niet gevonden' }
await prisma.$transaction([
prisma.user.updateMany({
where: { active_product_id: id },
data: { active_product_id: null },
}),
prisma.product.delete({ where: { id } }),
])
revalidatePath('/dashboard')
return { success: true }
}
export async function addProductMemberAction(_prevState: unknown, formData: FormData) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }

View file

@ -1,56 +1,63 @@
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 Link from 'next/link'
import { ProductList } from '@/components/dashboard/product-list'
import { NewProductButton } from '@/components/dashboard/new-product-button'
import { ProductsTable } from '@/components/dashboard/products-table'
import { ProductsTableToolbar } from '@/components/dashboard/products-table-toolbar'
import type { ProductsTableRow } from '@/components/dashboard/products-table'
interface Props {
searchParams: Promise<{ archived?: string }>
}
export default async function DashboardPage({ searchParams }: Props) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const { archived } = await searchParams
const showArchived = archived === '1'
export default async function DashboardPage() {
const session = await getIronSession<SessionData>(
await cookies(),
sessionOptions,
)
// Tabel-filter (archived) leeft client-side in useUserSettingsStore;
// server haalt alle toegankelijke producten + #PBI's in één query
// (geen N+1) en geeft alles door aan ProductsTable.
const [products, user] = await Promise.all([
prisma.product.findMany({
where: { archived: showArchived, ...productAccessFilter(session.userId) },
orderBy: { created_at: 'desc' },
where: productAccessFilter(session.userId ?? ''),
include: { _count: { select: { pbis: true } } },
orderBy: { updated_at: 'desc' },
}),
session.userId
? prisma.user.findUnique({ where: { id: session.userId }, select: { active_product_id: true } })
? prisma.user.findUnique({
where: { id: session.userId },
select: { active_product_id: true },
})
: null,
])
const rows: ProductsTableRow[] = products.map((p) => ({
id: p.id,
name: p.name,
code: p.code,
description: p.description,
repo_url: p.repo_url,
definition_of_done: p.definition_of_done,
auto_pr: p.auto_pr,
archived: p.archived,
pbiCount: p._count.pbis,
updated_at: p.updated_at,
}))
return (
<div className="p-6 max-w-4xl mx-auto w-full">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-medium text-foreground">
{showArchived ? 'Gearchiveerde producten' : 'Mijn Producten'}
</h1>
{showArchived ? (
<Link href="/dashboard" className="text-xs text-primary hover:underline">
Actief
</Link>
) : (
<Link href="/dashboard?archived=1" className="text-xs text-muted-foreground hover:text-foreground">
Toon gearchiveerd
</Link>
)}
</div>
{!session.isDemo && !showArchived && <NewProductButton />}
<div className="p-6 max-w-6xl mx-auto w-full space-y-4">
<div className="flex items-center justify-between gap-3">
<h1 className="text-xl font-medium text-foreground">Producten</h1>
{!session.isDemo && <NewProductButton />}
</div>
<ProductList
products={products}
isDemo={session.isDemo ?? false}
showArchived={showArchived}
<ProductsTableToolbar />
<ProductsTable
products={rows}
activeProductId={user?.active_product_id ?? null}
isDemo={session.isDemo ?? false}
/>
</div>
)

View file

@ -2,7 +2,6 @@ import { redirect, notFound } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { SetCurrentProduct } from '@/components/shared/set-current-product'
import { ProductSubNav } from '@/components/products/product-subnav'
interface Props {
children: React.ReactNode
@ -17,12 +16,9 @@ export default async function ProductLayout({ children, params }: Props) {
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const showDocs = product.enabled_doc_folders.length > 0
return (
<>
<SetCurrentProduct id={id} name={product.name} />
<ProductSubNav productId={id} showDocs={showDocs} />
{children}
</>
)

View file

@ -151,6 +151,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
}}
/>
)}
<Link
href={`/products/${id}/docs`}
className="text-xs text-muted-foreground hover:text-foreground"
>
Docs
</Link>
<Link
href={`/products/${id}/settings`}
className="text-xs text-muted-foreground hover:text-foreground"

View file

@ -0,0 +1,78 @@
'use client'
// AlertDialog-confirm voor product-delete vanuit de Dashboard-tabel.
// Controlled component — open/onOpenChange via parent zodat we 'm vanuit
// een DropdownMenuItem-klik kunnen openen. Patroon gespiegeld op
// components/product-docs/delete-product-doc-button.tsx (PBI-96).
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { deleteProductAction } from '@/actions/products'
interface Props {
productId: string
productName: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function DeleteProductConfirm({
productId,
productName,
open,
onOpenChange,
}: Props) {
const router = useRouter()
const [submitting, startSubmit] = useTransition()
function handleConfirm() {
startSubmit(async () => {
const r = await deleteProductAction(productId)
if (r && 'error' in r && r.error) {
toast.error(r.error)
return
}
toast.success('Product verwijderd')
onOpenChange(false)
router.refresh()
})
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Product verwijderen?</AlertDialogTitle>
<AlertDialogDescription>
&ldquo;{productName}&rdquo; wordt permanent verwijderd, inclusief
alle PBI&apos;s, stories, taken, jobs en docs. Niet ongedaan te
maken.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={submitting}>Annuleer</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={submitting}
variant="destructive"
data-debug-id="delete-product-confirm__action"
>
Ja, verwijder
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View file

@ -1,163 +0,0 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { restoreProductAction } from '@/actions/products'
import { setActiveProductAction } from '@/actions/active-product'
import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog'
import { debugProps } from '@/lib/debug'
interface Product {
id: string
name: string
code: string | null
description: string | null
repo_url: string | null
definition_of_done: string | null
auto_pr: boolean
}
interface ProductListProps {
products: Product[]
isDemo: boolean
showArchived?: boolean
activeProductId: string | null
}
export function ProductList({ products, isDemo, showArchived = false, activeProductId }: ProductListProps) {
const router = useRouter()
const [, startTransition] = useTransition()
const [editingProduct, setEditingProduct] = useState<ProductDialogProduct | null>(null)
function handleRestore(id: string) {
startTransition(async () => {
const result = await restoreProductAction(id)
if ('error' in result) toast.error(result.error ?? 'Herstellen mislukt')
else { toast.success('Product hersteld'); router.refresh() }
})
}
function handleActivate(id: string) {
startTransition(async () => {
const result = await setActiveProductAction(id)
if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Activeren mislukt')
else router.push(`/products/${id}`)
})
}
if (products.length === 0) {
return (
<div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}>
<p className="text-muted-foreground">
{showArchived
? 'Geen gearchiveerde producten.'
: 'Je hebt nog geen producten aangemaakt.'}
</p>
<DemoTooltip show={isDemo}>
<Button variant="outline" nativeButton={false} render={<Link href={isDemo ? '#' : '/products/new'} />} disabled={isDemo}>
Maak je eerste product aan
</Button>
</DemoTooltip>
</div>
)
}
return (
<div className="grid gap-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}>
{products.map(product => (
<div
key={product.id}
onClick={() => !showArchived && router.push(`/products/${product.id}`)}
className={`group bg-surface-container-low border border-border rounded-xl p-4 transition-colors ${
showArchived ? 'opacity-60' : 'cursor-pointer hover:border-primary'
}`}
data-debug-id="product-list__items"
>
<div className="flex items-start justify-between gap-4" data-debug-id="product-list__header">
<div className="min-w-0">
<div className="flex items-center gap-2">
{product.code && <CodeBadge code={product.code} />}
<p className="font-medium text-foreground group-hover:text-primary transition-colors truncate">
{product.name}
</p>
</div>
{product.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
{product.description.slice(0, 80)}{product.description.length > 80 ? '…' : ''}
</p>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
{product.repo_url && (
<a
href={product.repo_url}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="text-xs text-muted-foreground hover:text-primary underline"
>
Repo
</a>
)}
{!showArchived && (
<>
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) setEditingProduct(product) }}
className="opacity-0 group-hover:opacity-100 inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Bewerk product"
disabled={isDemo}
>
<Pencil size={14} />
</button>
</DemoTooltip>
{product.id === activeProductId
? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge>
: (
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }}
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline"
disabled={isDemo}
>
Activeer
</button>
</DemoTooltip>
)
}
</>
)}
{showArchived && (
<DemoTooltip show={isDemo}>
<button
onClick={(e) => { e.stopPropagation(); if (!isDemo) handleRestore(product.id) }}
className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed"
disabled={isDemo}
>
Herstellen
</button>
</DemoTooltip>
)}
</div>
</div>
</div>
))}
{editingProduct && (
<ProductDialog
mode="edit"
open={!!editingProduct}
onOpenChange={(v) => { if (!v) setEditingProduct(null) }}
product={editingProduct}
isDemo={isDemo}
/>
)}
</div>
)
}

View file

@ -0,0 +1,158 @@
'use client'
// Per-rij acties in de Dashboard ProductsTable. Inline: Activeer (of
// "Actief"-badge), Docs, Open backlog. Dropdown: Archive/Restore toggle.
// Verwijderen-item wordt in T-1089 toegevoegd zodra deleteProductAction
// bestaat. Alle write-knoppen DemoTooltip-wrapped + stopPropagation om
// te voorkomen dat een klik de rij-edit-dialog opent.
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition } from 'react'
import { ArrowRight, BookOpen, MoreHorizontal } from 'lucide-react'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import {
archiveProductAction,
restoreProductAction,
} from '@/actions/products'
import { DeleteProductConfirm } from '@/components/dashboard/delete-product-confirm'
import { debugProps } from '@/lib/debug'
interface Props {
productId: string
productName: string
isActive: boolean
isArchived: boolean
isDemo: boolean
}
export function ProductRowActions({
productId,
productName,
isActive,
isArchived,
isDemo,
}: Props) {
const router = useRouter()
const [submitting, startSubmit] = useTransition()
const [deleteOpen, setDeleteOpen] = useState(false)
function handleArchiveToggle() {
startSubmit(async () => {
if (isArchived) {
const r = await restoreProductAction(productId)
if (r && 'error' in r && r.error) {
toast.error(r.error)
} else {
toast.success('Product hersteld')
router.refresh()
}
} else {
// archiveProductAction doet redirect('/dashboard') — geen handmatige
// router.refresh nodig; bij fout returnt 'ie { error }.
const r = await archiveProductAction(productId)
if (r && 'error' in r && r.error) {
toast.error(r.error)
} else {
toast.success('Product gearchiveerd')
}
}
})
}
return (
<div
className="inline-flex items-center gap-1"
{...debugProps(
'product-row-actions',
'ProductRowActions',
'components/dashboard/product-row-actions.tsx',
)}
>
{isActive ? (
<Badge className="bg-primary-container text-primary-container-foreground text-[10px] px-1.5 py-0">
Actief
</Badge>
) : (
!isArchived && (
<ActivateProductButton productId={productId} isDemo={isDemo} />
)
)}
<Button
size="icon-sm"
variant="ghost"
aria-label="Docs"
title="Docs"
render={<Link href={`/products/${productId}/docs`} />}
data-debug-id="product-row-actions__docs"
>
<BookOpen className="size-3.5" />
</Button>
<Button
size="icon-sm"
variant="ghost"
aria-label="Open backlog"
title="Open backlog"
render={<Link href={`/products/${productId}`} />}
data-debug-id="product-row-actions__open"
>
<ArrowRight className="size-3.5" />
</Button>
<DropdownMenu>
<DemoTooltip show={isDemo}>
<DropdownMenuTrigger
render={
<Button
size="icon-sm"
variant="ghost"
aria-label="Meer acties"
disabled={isDemo || submitting}
data-debug-id="product-row-actions__more"
>
<MoreHorizontal className="size-3.5" />
</Button>
}
/>
</DemoTooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={handleArchiveToggle}
data-debug-id="product-row-actions__archive-toggle"
>
{isArchived ? 'Herstel' : 'Archiveer'}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
data-debug-id="product-row-actions__delete"
className="text-destructive focus:text-destructive"
>
Verwijderen
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DeleteProductConfirm
productId={productId}
productName={productName}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
)
}

View file

@ -0,0 +1,35 @@
// Empty-state voor de Dashboard products-tabel: wordt getoond wanneer de
// gebruiker (en alle leden van zijn ProductMember-set) nog géén producten
// hebben. Hergebruikt NewProductButton zodat de CTA dezelfde dialog opent
// als de header-knop.
import { NewProductButton } from '@/components/dashboard/new-product-button'
import { debugProps } from '@/lib/debug'
interface Props {
isDemo: boolean
}
export function ProductsEmptyState({ isDemo }: Props) {
return (
<div
className="rounded-xl border border-border bg-surface-container-low p-12 text-center space-y-4"
{...debugProps(
'products-empty-state',
'ProductsEmptyState',
'components/dashboard/products-empty-state.tsx',
)}
>
<div>
<p className="text-base font-medium">
Nog geen producten aangemaakt
</p>
<p className="text-sm text-muted-foreground mt-1">
Een product groepeert je backlog, sprints en docs. Maak er eentje aan
om te beginnen.
</p>
</div>
{!isDemo && <NewProductButton />}
</div>
)
}

View file

@ -0,0 +1,70 @@
'use client'
// Toolbar boven ProductsTable: search-input + "Inclusief gearchiveerd"-toggle.
// State leeft in useUserSettingsStore.views.productsTable (server-persisted).
// Search wordt debounced (200ms) om updateUserSettingsAction-spam bij typen
// te voorkomen; archived-toggle schrijft direct (één klik = één update).
import { useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { Input } from '@/components/ui/input'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { debugProps } from '@/lib/debug'
export function ProductsTableToolbar() {
const prefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.productsTable),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const storedSearch = prefs?.search ?? ''
const [localSearch, setLocalSearch] = useState(storedSearch)
// Debounce store-write: voorkomt N server-action calls per keystroke.
// Cleanup zorgt dat een unmount of nieuwe keystroke de vorige timer cancelt.
useEffect(() => {
if (localSearch === storedSearch) return
const t = setTimeout(() => {
void setPref(['views', 'productsTable', 'search'], localSearch)
}, 200)
return () => clearTimeout(t)
}, [localSearch, storedSearch, setPref])
const includeArchived = prefs?.includeArchived ?? false
return (
<div
className="flex items-center gap-4 flex-wrap"
{...debugProps(
'products-table-toolbar',
'ProductsTableToolbar',
'components/dashboard/products-table-toolbar.tsx',
)}
>
<Input
type="search"
placeholder="Zoek op naam of code…"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
className="max-w-xs"
data-debug-id="products-table-toolbar__search"
/>
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={includeArchived}
onChange={(e) =>
void setPref(
['views', 'productsTable', 'includeArchived'],
e.target.checked,
)
}
className="accent-primary"
data-debug-id="products-table-toolbar__archived-toggle"
/>
Inclusief gearchiveerd
</label>
</div>
)
}

View file

@ -0,0 +1,280 @@
'use client'
// Dashboard products-tabel (Ideas-stijl). Vervangt de oude grid-layout in
// components/dashboard/product-list.tsx (PBI-98/T-1086).
//
// - 6 kolommen: Code · Naam (+Actief-badge) · #PBI's · Status · Bijgewerkt · Acties
// - Sort via shared <SortHeader>; persist via useUserSettingsStore.views.productsTable
// - Filter via Toolbar (T-1087) — leest dezelfde store
// - Rij-klik → opent ProductDialog (edit-mode); actie-knoppen stoppen propagation
// - Acties-cell rendert <ProductRowActions> (T-1088); voor nu placeholder
import { useMemo, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { CodeBadge } from '@/components/shared/code-badge'
import { SortHeader } from '@/components/shared/sort-header'
import {
ProductDialog,
type ProductDialogProduct,
} from '@/components/dialogs/product-dialog'
import { ProductRowActions } from '@/components/dashboard/product-row-actions'
import { ProductsEmptyState } from '@/components/dashboard/products-empty-state'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { debugProps } from '@/lib/debug'
import { cn } from '@/lib/utils'
export interface ProductsTableRow {
id: string
name: string
code: string | null
description: string | null
repo_url: string | null
definition_of_done: string | null
auto_pr: boolean
archived: boolean
pbiCount: number
updated_at: Date
}
export type ProductsTableSortKey =
| 'code'
| 'name'
| 'pbiCount'
| 'status'
| 'updated_at'
interface ProductsTableProps {
products: ProductsTableRow[]
activeProductId: string | null
isDemo: boolean
}
const dateFmt = new Intl.DateTimeFormat('nl-NL', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
export function ProductsTable({
products,
activeProductId,
isDemo,
}: ProductsTableProps) {
const [editingProduct, setEditingProduct] =
useState<ProductDialogProduct | null>(null)
const prefs = useUserSettingsStore(
useShallow((s) => s.entities.settings.views?.productsTable),
)
const setPref = useUserSettingsStore((s) => s.setPref)
const search = (prefs?.search ?? '').trim().toLowerCase()
const includeArchived = prefs?.includeArchived ?? false
const sortCol: ProductsTableSortKey = (prefs?.sort ??
'updated_at') as ProductsTableSortKey
const sortDir: 'asc' | 'desc' = prefs?.sortDir ?? 'desc'
const filtered = useMemo(() => {
let rows = products
if (!includeArchived) rows = rows.filter((p) => !p.archived)
if (search) {
rows = rows.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
(p.code?.toLowerCase().includes(search) ?? false),
)
}
const sorted = [...rows].sort((a, b) => {
let cmp = 0
switch (sortCol) {
case 'code':
cmp = (a.code ?? '').localeCompare(b.code ?? '')
break
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'pbiCount':
cmp = a.pbiCount - b.pbiCount
break
case 'status':
cmp = Number(a.archived) - Number(b.archived)
break
case 'updated_at':
cmp = a.updated_at.getTime() - b.updated_at.getTime()
break
}
return sortDir === 'asc' ? cmp : -cmp
})
return sorted
}, [products, search, includeArchived, sortCol, sortDir])
function handleSort(col: ProductsTableSortKey) {
const newDir: 'asc' | 'desc' =
sortCol === col && sortDir === 'asc' ? 'desc' : 'asc'
void setPref(['views', 'productsTable', 'sort'], col)
void setPref(['views', 'productsTable', 'sortDir'], newDir)
}
// Vroege return: empty-state als de user nog geen enkel product heeft.
// Filter-resultaten (incl. archived-toggle) tonen we wel via tabel met
// "geen resultaten"-rij, zodat de filter-context zichtbaar blijft.
if (products.length === 0) {
return <ProductsEmptyState isDemo={isDemo} />
}
return (
<div
{...debugProps(
'products-table',
'ProductsTable',
'components/dashboard/products-table.tsx',
)}
>
<div className="rounded-lg border border-border bg-surface-container-low overflow-hidden">
<Table>
<TableHeader className="bg-surface-container">
<TableRow>
<TableHead className="w-[110px]">
<SortHeader<ProductsTableSortKey>
col="code"
label="Code"
sortKey={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
</TableHead>
<TableHead>
<SortHeader<ProductsTableSortKey>
col="name"
label="Naam"
sortKey={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
</TableHead>
<TableHead className="w-[80px] text-right">
<SortHeader<ProductsTableSortKey>
col="pbiCount"
label="PBI's"
sortKey={sortCol}
sortDir={sortDir}
onSort={handleSort}
className="ml-auto"
/>
</TableHead>
<TableHead className="w-[110px]">
<SortHeader<ProductsTableSortKey>
col="status"
label="Status"
sortKey={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
</TableHead>
<TableHead className="w-[110px]">
<SortHeader<ProductsTableSortKey>
col="updated_at"
label="Bijgewerkt"
sortKey={sortCol}
sortDir={sortDir}
onSort={handleSort}
/>
</TableHead>
<TableHead className="w-[200px] text-right">Acties</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell
colSpan={6}
className="text-center text-sm text-muted-foreground py-8"
>
Geen producten passen bij de huidige filters.
</TableCell>
</TableRow>
) : (
filtered.map((product) => (
<TableRow
key={product.id}
onClick={() => setEditingProduct(product)}
className={cn(
'cursor-pointer hover:bg-surface-container/60',
product.archived && 'opacity-60',
)}
data-debug-id="products-table__row"
>
<TableCell>
{product.code ? (
<CodeBadge code={product.code} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-medium">{product.name}</span>
{product.id === activeProductId && (
<Badge className="bg-primary-container text-primary-container-foreground text-[10px] px-1.5 py-0">
Actief
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right tabular-nums text-xs text-muted-foreground">
{product.pbiCount}
</TableCell>
<TableCell>
{product.archived ? (
<Badge className="bg-muted/50 text-muted-foreground text-[10px] uppercase tracking-wide px-1.5 py-0">
Archived
</Badge>
) : null}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">
{dateFmt.format(product.updated_at)}
</TableCell>
<TableCell
className="text-right"
onClick={(e) => e.stopPropagation()}
data-debug-id="products-table__actions-cell"
>
<ProductRowActions
productId={product.id}
productName={product.name}
isActive={product.id === activeProductId}
isArchived={product.archived}
isDemo={isDemo}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{editingProduct && (
<ProductDialog
mode="edit"
open={!!editingProduct}
onOpenChange={(v) => {
if (!v) setEditingProduct(null)
}}
product={editingProduct}
isDemo={isDemo}
/>
)}
</div>
)
}

View file

@ -1,6 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
@ -287,20 +288,33 @@ export function ProductDialog(props: Props) {
</form>
<div className={entityDialogFooterClasses}>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo} data-debug-id="product-dialog__submit">
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
<div className="flex items-center justify-between gap-2">
{mode === 'edit' && product?.id ? (
<Link
href={`/products/${product.id}/docs/settings`}
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
data-debug-id="product-dialog__docs-settings-link"
>
Naar docs-instellingen
</Link>
) : (
<span />
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button type="submit" form="product-form" disabled={isPending || isDemo} data-debug-id="product-dialog__submit">
{isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'}
</Button>
</DemoTooltip>
</div>
</div>
</div>
</DialogContent>

View file

@ -10,7 +10,7 @@
import { useMemo, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react'
import { Plus } from 'lucide-react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
@ -30,6 +30,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { SortHeader } from '@/components/shared/sort-header'
import { getIdeaStatusBadge } from '@/lib/idea-status-colors'
import type { IdeaStatusApi } from '@/lib/idea-status'
import type { IdeaDto } from '@/lib/idea-dto'
@ -88,38 +89,6 @@ const STATUS_SORT_ORDER: Record<IdeaStatusApi, number> = {
planned: 7, grill_failed: 8, plan_failed: 9, plan_review_failed: 10,
}
function SortHeader({
col,
label,
sortKey,
sortDir,
onSort,
}: {
col: SortKey
label: string
sortKey: SortKey
sortDir: 'asc' | 'desc'
onSort: (col: SortKey) => void
}) {
const active = sortKey === col
const Icon = active
? sortDir === 'asc' ? ArrowUp : ArrowDown
: ArrowUpDown
return (
<button
type="button"
onClick={() => onSort(col)}
className={cn(
'flex items-center gap-1 text-xs font-medium hover:text-foreground transition-colors',
active ? 'text-foreground' : 'text-muted-foreground'
)}
>
{label}
<Icon className="size-3" />
</button>
)
}
export function IdeaList({ ideas, products, isDemo, activeProductId }: IdeaListProps) {
const router = useRouter()
const [isPending, startTransition] = useTransition()

View file

@ -1,98 +0,0 @@
'use client'
// Per-product sub-navigation. Toont tabs voor de verschillende product-
// pages (Backlog, Sprint, Solo, Docs, Instellingen). Active-state via
// `usePathname`. Patroon gebaseerd op `navLink` uit components/shared/nav-bar.tsx.
//
// Plan: docs/plans/PBI-96-product-docs.md §C.2.
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
import { debugProps } from '@/lib/debug'
interface Props {
productId: string
/** Wanneer false: verberg de Docs-tab (geen folders enabled). */
showDocs?: boolean
}
interface Tab {
href: string
label: string
isActive: (pathname: string) => boolean
}
export function ProductSubNav({ productId, showDocs = true }: Props) {
const pathname = usePathname()
const base = `/products/${productId}`
const tabs: Tab[] = [
{
href: base,
label: 'Backlog',
isActive: (p) =>
p === base ||
(p.startsWith(base) &&
!p.startsWith(`${base}/sprint`) &&
!p.startsWith(`${base}/solo`) &&
!p.startsWith(`${base}/docs`) &&
!p.startsWith(`${base}/settings`)),
},
{
href: `${base}/sprint`,
label: 'Sprint',
isActive: (p) => p.startsWith(`${base}/sprint`),
},
{
href: `${base}/solo`,
label: 'Solo',
isActive: (p) => p.startsWith(`${base}/solo`),
},
...(showDocs
? [
{
href: `${base}/docs`,
label: 'Docs',
isActive: (p: string) => p.startsWith(`${base}/docs`),
},
]
: []),
{
href: `${base}/settings`,
label: 'Instellingen',
isActive: (p) => p.startsWith(`${base}/settings`),
},
]
return (
<nav
className="border-b border-border bg-surface-container-low px-4 py-2 flex items-center gap-1 shrink-0"
{...debugProps(
'product-subnav',
'ProductSubNav',
'components/products/product-subnav.tsx',
)}
>
{tabs.map((tab) => {
const active = tab.isActive(pathname)
return (
<Link
key={tab.label}
href={tab.href}
className={cn(
'px-3 py-1 rounded-md text-xs transition-colors',
active
? 'bg-primary-container text-primary-container-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-surface-container',
)}
data-debug-id={`product-subnav__tab--${tab.label.toLowerCase()}`}
>
{tab.label}
</Link>
)
})}
</nav>
)
}

View file

@ -0,0 +1,48 @@
'use client'
// Generic sort-header voor tabel-kolommen. Extract uit
// components/ideas/idea-list.tsx (PBI-98/T-1085) zodat zowel /ideas als
// /dashboard products-tabel hetzelfde sort-pattern delen.
//
// Generieke type-parameter TKey laat de caller zijn eigen kolom-set
// definiëren (bv. type SortKey = 'code' | 'title' | 'status').
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface SortHeaderProps<TKey extends string> {
col: TKey
label: string
sortKey: TKey
sortDir: 'asc' | 'desc'
onSort: (col: TKey) => void
className?: string
}
export function SortHeader<TKey extends string>({
col,
label,
sortKey,
sortDir,
onSort,
className,
}: SortHeaderProps<TKey>) {
const active = sortKey === col
const Icon = active ? (sortDir === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown
return (
<button
type="button"
onClick={() => onSort(col)}
className={cn(
'flex items-center gap-1 text-xs font-medium hover:text-foreground transition-colors',
active ? 'text-foreground' : 'text-muted-foreground',
className,
)}
>
{label}
<Icon className="size-3" aria-hidden="true" />
</button>
)
}

View file

@ -52,8 +52,14 @@ N.v.t. — Product heeft geen status-enum. `archived` is een boolean buiten dit
- **Code-uniqueness server-side**: bij conflict wordt `fieldErrors.code` gezet; veld krijgt rode rand.
- **`useProductsStore` updates**: na succesvolle save wordt de in-memory store synchroon bijgewerkt zodat de productlijst onmiddellijk reageert (lokaal-first).
## Cross-link "Naar docs-instellingen" (PBI-98)
In edit-mode toont de footer links een `<Link>` naar `/products/[id]/docs/settings` (de folder-toggle-pagina uit PBI-96). Layout: `flex justify-between` zodat de link aan de linkerkant en de bestaande knoppen (Annuleren + Opslaan) aan de rechterkant blijven. In create-mode is de link verborgen (product bestaat nog niet); een `<span />` placeholder behoudt de right-align van de knoppen.
Tap-doel: `text-xs text-primary hover:underline`. Klik = `router.push`; de dirty-close-guard blijft intact omdat het géén save-flow is.
## Bewust NIET in v1
- Verwijderen vanuit deze dialog (loopt via `archiveProductAction` op een andere knop)
- Verwijderen vanuit deze dialog (loopt via `deleteProductAction` op de Dashboard row-actions dropdown)
- Bulk edit
- Members beheren (eigen scherm op `/products/[id]/settings`)

View file

@ -181,24 +181,35 @@ De instellingenpagina toont een gecombineerde lijst van alle product backlogs wa
**Omschrijving:**
Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het hoogste niveau in de hiërarchie en bevat een naam, beschrijving, git-repo URL en de Definition of Done. Alle andere entiteiten (PBI's, stories, taken) horen bij een product.
**Acceptatiecriteria:**
**Acceptatiecriteria (CRUD via dialog):**
- [ ] Product aanmaken vereist een naam (uniek per gebruiker, verplicht)
- [ ] Beschrijving is optioneel (vrije tekst, max. 1000 tekens)
- [ ] Git-repo URL is optioneel; wordt gevalideerd als geldige URL bij invullen
- [ ] Definition of Done is een vaste tekst, instelbaar per product (verplicht bij aanmaken, max. 500 tekens)
- [ ] Product verschijnt direct in de productenlijst na aanmaken
- [ ] Naam en alle andere velden zijn bewerkbaar na aanmaken
- [ ] Naam en alle andere velden zijn bewerkbaar na aanmaken via `ProductDialog`
- [ ] Edit-mode van `ProductDialog` toont cross-link "Naar docs-instellingen →" (PBI-98)
- [ ] Archiveren is omkeerbaar; gearchiveerde producten zijn standaard verborgen
- [ ] Productenlijst toont: naam, beschrijving (ingekort tot 80 tekens), git-repo link (indien aanwezig)
- [ ] Klikken op een product opent de Product Backlog van dat product
- [ ] Lege staat toont een duidelijke prompt om een eerste product aan te maken
- [ ] Verwijderen vereist AlertDialog-bevestiging; cascade-delete via Prisma onDelete (PBI/Story/Task/Doc)
**Acceptatiecriteria (`/dashboard` tabel — PBI-98):**
- [ ] Tabel met kolommen Code · Naam (+`Actief`-badge) · #PBI's · Status · Bijgewerkt · Acties
- [ ] Sorteerbare headers op alle 5 data-kolommen; default sort = bijgewerkt desc
- [ ] Search-input boven tabel filtert op naam + code (debounced 200ms)
- [ ] "Inclusief gearchiveerd"-toggle filtert archived in/uit (default uit)
- [ ] Filter/sort state persist via `useUserSettingsStore.views.productsTable` (server-synced)
- [ ] Rij-klik opent `ProductDialog` in edit-mode (acties-knoppen hebben `stopPropagation`)
- [ ] Acties-kolom: Activeer (of `Actief`-badge), Docs (→ `/docs`), Open backlog (→ `/products/[id]`), dropdown (Archiveer/Herstel, Verwijderen)
- [ ] Demo-user: write-knoppen disabled + DemoTooltip; Docs/Backlog/Search/Sort werken
- [ ] Lege staat (0 producten): grote CTA `ProductsEmptyState` met `NewProductButton`
**Randgevallen:**
- Gebruiker probeert naam leeg te maken bij bewerken → validatiefout, opslaan geblokkeerd
- Git-repo URL zonder `https://` → validatiefout met suggestie
- Verwijderen van actief product → `active_product_id` van alle users wordt eerst genulleerd, daarna delete
**Data:**
- Opgeslagen: `products` (id, user_id, name, description, repo_url, definition_of_done, archived, created_at, updated_at)
- Opgeslagen: `products` (id, user_id, name, description, repo_url, definition_of_done, archived, enabled_doc_folders, created_at, updated_at)
- Tabel-prefs: `users.settings.views.productsTable` (search, includeArchived, sort, sortDir)
---

View file

@ -44,6 +44,16 @@ const IdeasListPrefs = z.object({
).optional(),
}).strict()
// PBI-98 — Dashboard products-tabel filter/sort prefs.
// Defaults via fallbacks in de component (`search ?? ''`,
// `includeArchived ?? false`, `sort ?? 'updated_at'`, `sortDir ?? 'desc'`).
const ProductsTablePrefs = z.object({
search: z.string().optional(),
includeArchived: z.boolean().optional(),
sort: z.enum(['code', 'name', 'pbiCount', 'status', 'updated_at']).optional(),
sortDir: SortDir.optional(),
}).strict()
const ViewsPrefs = z.object({
sprintBacklog: SprintBacklogPrefs.optional(),
pbiList: PbiListPrefs.optional(),
@ -51,6 +61,7 @@ const ViewsPrefs = z.object({
jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(),
jobs: JobsViewPrefs.optional(),
ideasList: IdeasListPrefs.optional(),
productsTable: ProductsTablePrefs.optional(),
}).strict()
const DevToolsPrefs = z.object({