feat(PBI-96/T-1067): Product Docs INDEX-page + grid + folder-card

- app/(app)/products/[id]/docs/page.tsx: server-component die met één
  prisma.findMany de 3 meest recent bijgewerkte docs per enabled folder
  laadt en doorgeeft aan ProductDocsIndex.
- components/product-docs/product-docs-index.tsx: grid van enabled
  folders (vaste FOLDER_ORDER), folder-labels, settings-link. Toont
  empty-state als 0 folders aanstaan.
- components/product-docs/product-docs-folder-card.tsx: card per folder
  met titel + omschrijving + count + 3 doc-links of CTA bij leeg.
- MD3-tokens (bg-surface-container-low, border-border, text-primary);
  GEEN bg-blue-500 (CLAUDE.md hardstop).
- debugProps op alle root-divs (debug-id pattern).
- Disabled folders worden niet getoond in INDEX (verborgen) maar
  blijven via directe URL bereikbaar — banner-flow 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:26:52 +02:00
parent 7212192544
commit 628857873e
3 changed files with 273 additions and 0 deletions

View file

@ -0,0 +1,73 @@
// Product Docs INDEX-pagina (PBI-96 / T-1067). Server-component.
//
// Loadt het product + de meest-recente 3 docs per enabled folder, en
// rendert de grid via ProductDocsIndex. Disabled folders worden NIET
// getoond op de INDEX maar blijven via directe URL bereikbaar (zie
// plan §C.4 / T-1071 voor de banner-flow).
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import {
ProductDocsIndex,
} from '@/components/product-docs/product-docs-index'
import type { ProductDocCardItem } from '@/components/product-docs/product-docs-folder-card'
import type { ProductDocFolder } from '@prisma/client'
interface Props {
params: Promise<{ id: string }>
}
export default async function ProductDocsIndexPage({ params }: Props) {
const { id } = await params
const session = await getSession()
if (!session.userId) redirect('/login')
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const enabledFolders = product.enabled_doc_folders
// Eén findMany, in-memory groeperen + slicen tot 3 per folder. Voorkomt
// 8 separate queries; voor doc-aantallen tot ~100 is dit verwaarloosbaar.
const recentDocs =
enabledFolders.length === 0
? []
: await prisma.productDoc.findMany({
where: { product_id: id, folder: { in: enabledFolders } },
select: {
id: true,
folder: true,
slug: true,
title: true,
status: true,
updated_at: true,
},
orderBy: [{ updated_at: 'desc' }],
})
const docsByFolder: Partial<Record<ProductDocFolder, ProductDocCardItem[]>> = {}
for (const doc of recentDocs) {
const bucket = docsByFolder[doc.folder] ?? []
if (bucket.length < 3) {
bucket.push({
id: doc.id,
slug: doc.slug,
title: doc.title,
status: doc.status,
updated_at: doc.updated_at,
})
docsByFolder[doc.folder] = bucket
}
}
return (
<ProductDocsIndex
productId={id}
enabledFolders={enabledFolders}
docsByFolder={docsByFolder}
/>
)
}

View file

@ -0,0 +1,86 @@
// Card per folder op de Product Docs INDEX-pagina. Server-component.
// Toont folder-titel + omschrijving + telling + de laatste 3 docs (of CTA
// bij lege folder). MD3-tokens, géén bg-blue-500 (CLAUDE.md hardstop).
import Link from 'next/link'
import { FileText, Plus } from 'lucide-react'
import { productDocFolderToApi } from '@/lib/product-doc-folder'
import { debugProps } from '@/lib/debug'
import type { ProductDocFolder } from '@prisma/client'
export interface ProductDocCardItem {
id: string
slug: string
title: string
status: string
updated_at: Date
}
interface Props {
productId: string
folder: ProductDocFolder
label: { title: string; description: string }
docs: ProductDocCardItem[]
}
export function ProductDocsFolderCard({ productId, folder, label, docs }: Props) {
const folderApi = productDocFolderToApi(folder)
const folderUrl = `/products/${productId}/docs/${folderApi}`
return (
<div
className="rounded-lg border border-border bg-surface-container-low p-4 space-y-3"
{...debugProps(
`product-docs-folder-card--${folderApi}`,
'ProductDocsFolderCard',
'components/product-docs/product-docs-folder-card.tsx',
)}
>
<div className="flex items-baseline justify-between gap-2">
<div className="min-w-0">
<Link
href={folderUrl}
className="text-sm font-semibold hover:underline"
>
{label.title}
</Link>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{label.description}
</p>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{docs.length} {docs.length === 1 ? 'doc' : 'docs'}
</span>
</div>
{docs.length === 0 ? (
<div className="text-xs text-muted-foreground space-y-2">
<p>Nog geen docs in deze folder.</p>
<Link
href={`${folderUrl}?new=1`}
className="inline-flex items-center gap-1 text-primary hover:underline"
data-debug-id={`product-docs-folder-card--${folderApi}__cta`}
>
<Plus className="size-3" />
Maak eerste doc
</Link>
</div>
) : (
<ul className="space-y-1">
{docs.map((doc) => (
<li key={doc.id}>
<Link
href={`${folderUrl}/${doc.slug}`}
className="flex items-center gap-2 text-xs hover:bg-surface-container px-1 py-1 -mx-1 rounded transition-colors"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{doc.title}</span>
</Link>
</li>
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,114 @@
// INDEX-grid voor Product Docs. Server-component. Toont alleen folders
// die in product.enabled_doc_folders staan, in vaste volgorde.
import Link from 'next/link'
import { debugProps } from '@/lib/debug'
import type { ProductDocFolder } from '@prisma/client'
import {
ProductDocsFolderCard,
type ProductDocCardItem,
} from './product-docs-folder-card'
interface Props {
productId: string
enabledFolders: ProductDocFolder[]
/** Per-folder lijst met (max 3) meest recent bijgewerkte docs. */
docsByFolder: Partial<Record<ProductDocFolder, ProductDocCardItem[]>>
}
// Vaste display-order (matcht Scrum4Me's eigen docs-tree).
const FOLDER_ORDER: ProductDocFolder[] = [
'ADR',
'ARCHITECTURE',
'PATTERNS',
'PLANS',
'RUNBOOKS',
'SPECS',
'MANUAL',
'API',
]
export const FOLDER_LABELS: Record<
ProductDocFolder,
{ title: string; description: string }
> = {
ADR: { title: 'ADRs', description: 'Architecture Decision Records' },
ARCHITECTURE: { title: 'Architecture', description: 'Topische arch-bestanden' },
PATTERNS: { title: 'Patterns', description: 'Herbruikbare code-patronen' },
PLANS: { title: 'Plans', description: 'Feature- en PBI-plannen' },
RUNBOOKS: { title: 'Runbooks', description: 'Operationele procedures' },
SPECS: { title: 'Specs', description: 'Functionele specificaties' },
MANUAL: { title: 'Manual', description: 'Developer manual' },
API: { title: 'API', description: 'API-contract details' },
}
export function ProductDocsIndex({
productId,
enabledFolders,
docsByFolder,
}: Props) {
const orderedEnabled = FOLDER_ORDER.filter((f) => enabledFolders.includes(f))
if (orderedEnabled.length === 0) {
return (
<div
className="p-8 text-center space-y-3"
{...debugProps(
'product-docs-index--empty',
'ProductDocsIndex',
'components/product-docs/product-docs-index.tsx',
)}
>
<p className="text-sm text-muted-foreground">
Geen documentatie-folders ingeschakeld voor dit product.
</p>
<Link
href={`/products/${productId}/docs/settings`}
className="text-xs text-primary hover:underline"
>
Folders configureren
</Link>
</div>
)
}
return (
<div
className="p-6 space-y-4"
{...debugProps(
'product-docs-index',
'ProductDocsIndex',
'components/product-docs/product-docs-index.tsx',
)}
>
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">Documentatie</h1>
<p className="text-xs text-muted-foreground mt-0.5">
{orderedEnabled.length} van 8 folders ingeschakeld
</p>
</div>
<Link
href={`/products/${productId}/docs/settings`}
className="text-xs text-muted-foreground hover:text-foreground"
data-debug-id="product-docs-index__settings-link"
>
Folder-config
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{orderedEnabled.map((folder) => (
<ProductDocsFolderCard
key={folder}
productId={productId}
folder={folder}
label={FOLDER_LABELS[folder]}
docs={docsByFolder[folder] ?? []}
/>
))}
</div>
</div>
)
}