feat(PBI-96/T-1068): folder-listing page + folder-list + status-badge

- app/(app)/products/[id]/docs/[folder]/page.tsx: server-route die
  folder-segment valideert tegen ProductDocFolder-enum (404 anders),
  docs sorteert op slug ASC, en de tabel + breadcrumb + "Nieuwe doc"-link
  rendert. New-doc-link wordt in T-1070 functioneel via dialog.
- components/product-docs/product-docs-folder-list.tsx: server-tabel
  (slug · title · status-badge · updated_at met nl-NL DateTimeFormat).
- components/product-docs/product-doc-status-badge.tsx: MD3-tokens
  (bg-status-done/20, bg-status-blocked/20, bg-muted) per status.
  Unknown statussen fallbacken naar 'muted'.
- "Nieuwe doc"-knop conditioneel verborgen bij disabled folder; banner
  zelf komt in T-1071.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 14:27:55 +02:00
parent 628857873e
commit 3c8699f8e7
3 changed files with 234 additions and 0 deletions

View file

@ -0,0 +1,97 @@
// Folder-listing page (PBI-96 / T-1068). Server-component.
//
// Valideert de folder-segment tegen ProductDocFolder-enum (404 anders).
// Toont een tabel met alle docs in deze folder (sortering [slug ASC]).
// "Nieuwe doc" knop wordt in T-1070 functioneel via een dialog; voor nu
// is het een link naar `?new=1`. Disabled-folder banner komt in T-1071.
import { notFound, redirect } from 'next/navigation'
import Link from 'next/link'
import { ArrowLeft, Plus } from 'lucide-react'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { productDocFolderFromApi } from '@/lib/product-doc-folder'
import { FOLDER_LABELS } from '@/components/product-docs/product-docs-index'
import {
ProductDocsFolderList,
type ProductDocListRow,
} from '@/components/product-docs/product-docs-folder-list'
interface Props {
params: Promise<{ id: string; folder: string }>
}
export default async function ProductDocsFolderPage({ params }: Props) {
const { id, folder: folderApiParam } = await params
const session = await getSession()
if (!session.userId) redirect('/login')
const folderDb = productDocFolderFromApi(folderApiParam)
if (!folderDb) notFound()
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const docs = await prisma.productDoc.findMany({
where: { product_id: id, folder: folderDb },
select: {
id: true,
slug: true,
title: true,
status: true,
updated_at: true,
},
orderBy: [{ slug: 'asc' }],
})
const rows: ProductDocListRow[] = docs.map((d) => ({
id: d.id,
slug: d.slug,
title: d.title,
status: d.status,
updated_at: d.updated_at,
}))
const label = FOLDER_LABELS[folderDb]
const folderApi = folderApiParam.toLowerCase()
const isFolderEnabled = product.enabled_doc_folders.includes(folderDb)
return (
<div className="p-6 space-y-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Link
href={`/products/${id}/docs`}
className="inline-flex items-center gap-1 hover:text-foreground"
>
<ArrowLeft className="size-3" />
Documentatie
</Link>
<span>/</span>
<span className="text-foreground font-medium">{label.title}</span>
</div>
<div className="flex items-baseline justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">{label.title}</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{label.description}
</p>
</div>
{isFolderEnabled && (
<Link
href={`/products/${id}/docs/${folderApi}?new=1`}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
data-debug-id="product-docs-folder-page__new-doc"
>
<Plus className="size-3" />
Nieuwe doc
</Link>
)}
</div>
<ProductDocsFolderList productId={id} folderApi={folderApi} docs={rows} />
</div>
)
}

View file

@ -0,0 +1,43 @@
// Status-badge voor ProductDoc.status. MD3-tokens; géén bg-blue-500
// (CLAUDE.md hardstop). Status komt uit frontmatter dus theoretisch
// een free-form string — onbekende waarden krijgen de neutrale 'muted'
// stijl.
import { cn } from '@/lib/utils'
const STATUS_CLASS: Record<string, string> = {
draft: 'bg-muted text-muted-foreground',
active: 'bg-status-done/20 text-status-done',
deprecated: 'bg-status-blocked/20 text-status-blocked',
archived: 'bg-muted/50 text-muted-foreground',
}
const STATUS_LABEL: Record<string, string> = {
draft: 'draft',
active: 'active',
deprecated: 'deprecated',
archived: 'archived',
}
interface Props {
status: string
className?: string
}
export function ProductDocStatusBadge({ status, className }: Props) {
const normalized = status.toLowerCase()
const klass = STATUS_CLASS[normalized] ?? 'bg-muted text-muted-foreground'
const label = STATUS_LABEL[normalized] ?? status
return (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
klass,
className,
)}
data-debug-id={`product-doc-status-badge--${normalized}`}
>
{label}
</span>
)
}

View file

@ -0,0 +1,94 @@
// Folder-listing tabel. Server-component (geen interactiviteit in v1 —
// sorteren komt later via TanStack-table indien nodig). Toont
// slug · title · status-badge · updated_at.
import Link from 'next/link'
import { FileText } from 'lucide-react'
import { debugProps } from '@/lib/debug'
import { ProductDocStatusBadge } from './product-doc-status-badge'
export interface ProductDocListRow {
id: string
slug: string
title: string
status: string
updated_at: Date
}
interface Props {
productId: string
folderApi: string
docs: ProductDocListRow[]
}
const dateFmt = new Intl.DateTimeFormat('nl-NL', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
export function ProductDocsFolderList({ productId, folderApi, docs }: Props) {
if (docs.length === 0) {
return (
<div
className="text-sm text-muted-foreground p-6 text-center"
{...debugProps(
'product-docs-folder-list--empty',
'ProductDocsFolderList',
'components/product-docs/product-docs-folder-list.tsx',
)}
>
Nog geen docs in deze folder.
</div>
)
}
const docsUrl = `/products/${productId}/docs/${folderApi}`
return (
<div
className="rounded-lg border border-border bg-surface-container-low overflow-hidden"
{...debugProps(
'product-docs-folder-list',
'ProductDocsFolderList',
'components/product-docs/product-docs-folder-list.tsx',
)}
>
<table className="w-full text-sm">
<thead className="border-b border-border bg-surface-container">
<tr className="text-xs text-muted-foreground">
<th className="text-left px-3 py-2 font-medium">Slug</th>
<th className="text-left px-3 py-2 font-medium">Titel</th>
<th className="text-left px-3 py-2 font-medium">Status</th>
<th className="text-right px-3 py-2 font-medium">Bijgewerkt</th>
</tr>
</thead>
<tbody>
{docs.map((doc) => (
<tr
key={doc.id}
className="border-t border-border first:border-t-0 hover:bg-surface-container/60"
>
<td className="px-3 py-2 font-mono text-xs">
<Link
href={`${docsUrl}/${doc.slug}`}
className="text-primary hover:underline inline-flex items-center gap-1.5"
>
<FileText className="size-3 shrink-0" />
{doc.slug}
</Link>
</td>
<td className="px-3 py-2 truncate max-w-[420px]">{doc.title}</td>
<td className="px-3 py-2">
<ProductDocStatusBadge status={doc.status} />
</td>
<td className="px-3 py-2 text-right text-xs text-muted-foreground tabular-nums">
{dateFmt.format(doc.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}