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
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:
commit
03eb1432ab
21 changed files with 1013 additions and 409 deletions
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
72
__tests__/components/dashboard/product-row-actions.test.tsx
Normal file
72
__tests__/components/dashboard/product-row-actions.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
37
__tests__/components/dashboard/products-empty-state.test.tsx
Normal file
37
__tests__/components/dashboard/products-empty-state.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
96
__tests__/components/shared/sort-header.test.tsx
Normal file
96
__tests__/components/shared/sort-header.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
78
components/dashboard/delete-product-confirm.tsx
Normal file
78
components/dashboard/delete-product-confirm.tsx
Normal 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>
|
||||
“{productName}” wordt permanent verwijderd, inclusief
|
||||
alle PBI'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
158
components/dashboard/product-row-actions.tsx
Normal file
158
components/dashboard/product-row-actions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
components/dashboard/products-empty-state.tsx
Normal file
35
components/dashboard/products-empty-state.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
components/dashboard/products-table-toolbar.tsx
Normal file
70
components/dashboard/products-table-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
components/dashboard/products-table.tsx
Normal file
280
components/dashboard/products-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
48
components/shared/sort-header.tsx
Normal file
48
components/shared/sort-header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue