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:
parent
7212192544
commit
628857873e
3 changed files with 273 additions and 0 deletions
73
app/(app)/products/[id]/docs/page.tsx
Normal file
73
app/(app)/products/[id]/docs/page.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
86
components/product-docs/product-docs-folder-card.tsx
Normal file
86
components/product-docs/product-docs-folder-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
components/product-docs/product-docs-index.tsx
Normal file
114
components/product-docs/product-docs-index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue