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:
parent
628857873e
commit
3c8699f8e7
3 changed files with 234 additions and 0 deletions
97
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal file
97
app/(app)/products/[id]/docs/[folder]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
components/product-docs/product-doc-status-badge.tsx
Normal file
43
components/product-docs/product-doc-status-badge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
components/product-docs/product-docs-folder-list.tsx
Normal file
94
components/product-docs/product-docs-folder-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue