22 KiB
| name | description |
|---|---|
| resource-details-page | Create resource details pages with tabbed layouts, context providers, and sub-pages for the carage-erp dashboard. Use when: building a details/show page for a resource, adding tabs to a resource detail view, creating a nested [id] layout, scaffolding sub-pages (owners, documents, estimates), implementing resource context providers, or adding actions menus to detail headers. |
Resource Details Page
Create fully structured resource details pages with tabbed navigation, shared context, and reusable sub-page patterns. This skill covers: layout → context provider → general-info component → actions component → tab sub-pages.
When to Use
- User asks to create a details/show page for a resource (e.g. "create a customer details page")
- User asks to add tabs to a resource (e.g. "add an invoices tab to the vehicle page")
- User asks to scaffold a
[id]layout with nested pages - User wants sub-pages that share parent resource data (e.g. vehicle → estimates)
- User wants an actions dropdown (edit/delete) on a resource header
Reference Implementation
The canonical implementation is the vehicle details page at:
app/(authenticated)/sales/vehicles/[id]/
├── layout.tsx ← Server component: fetches resource, renders DashboardDetailsPage
├── page.tsx ← Details tab: server component, read-only info display
├── owners/page.tsx ← Sub-tab: client component, manual DataTable
├── documents/page.tsx ← Sub-tab: client component, manual DataTable + upload
├── estimates/page.tsx ← Sub-tab: client component, ResourcePage with extraParams
Module files at:
modules/vehicles/
├── vehicle.schema.ts
├── vehicle-form.tsx
├── vehicle-general-info.tsx ← Read-only info cards for Details tab
├── vehicle-actions.tsx ← Dropdown menu (Edit/Delete)
├── vehicle-context.tsx ← React context for sharing resource data to sub-pages
└── vehicle-document-form.tsx ← Document-specific form (optional)
Procedure
Step 1: Create the Resource Context
Create modules/<resource>/<resource>-context.tsx:
This context allows child tab pages to access the parent resource's identity (id + display label) without re-fetching. This is essential for sub-pages that create related records (e.g. creating an estimate pre-populated with the parent vehicle).
"use client"
import { createContext, useContext } from "react"
type <Resource>ContextValue = {
id: string
label: string
}
const <Resource>Context = createContext<<Resource>ContextValue | null>(null)
export function <Resource>Provider({
<resource>,
children,
}: {
<resource>: <Resource>ContextValue
children: React.ReactNode
}) {
return (
<<Resource>Context.Provider value={<resource>}>
{children}
</<Resource>Context.Provider>
)
}
export function use<Resource>() {
return useContext(<Resource>Context)
}
Key rules:
- Always a
"use client"component (context requires client React) - Keep the context value minimal — only
idandlabel labelis a human-readable display string built from the resource's key fields- The hook returns
nullwhen used outside the provider (no throw — graceful fallback) - Do NOT store the full resource object — only identity data needed by sub-pages
Step 2: Create the Actions Component
Create modules/<resource>/<resource>-actions.tsx:
This is a dropdown menu rendered in the DashboardDetailsPage header for resource-level actions.
"use client"
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { Ellipsis, Pencil, Trash2 } from "lucide-react"
type <Resource>ActionsProps = {
<resource>Id: string
}
export function <Resource>Actions({ <resource>Id }: <Resource>ActionsProps) {
const api = useAuthApi()
const router = useRouter()
const handleEdit = () => {
router.push(`/<section>/<resources>/${<resource>Id}/edit`)
}
const handleDelete = async () => {
await api.<resources>.destroy(<resource>Id)
router.push("/<section>/<resources>")
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
Key rules:
- Always a
"use client"component - Accepts
<resource>Id: stringas the only required prop - Uses
useAuthApi()for API calls,useRouter()for navigation handleDeletenavigates back to the list page after deletion- Add confirmation dialog for destructive actions when appropriate
Step 3: Create the General Info Component
Create modules/<resource>/<resource>-general-info.tsx:
A read-only display component for the Details tab (the default page.tsx). Uses Card-based layout with icon-labeled fields.
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"
// Import relevant icons from lucide-react
type <Resource>Data = {
// Type all fields from the API response
id?: number
name?: string
// ...
}
type <Resource>GeneralInfoProps = {
<resource>: <Resource>Data
}
function InfoItem({
icon: Icon,
label,
value,
}: {
icon: React.ComponentType<{ className?: string }>
label: string
value?: string | null
}) {
return (
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium">
{value || <span className="text-muted-foreground">—</span>}
</span>
</div>
</div>
)
}
export function <Resource>GeneralInfo({ <resource> }: <Resource>GeneralInfoProps) {
return (
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{/* Icon */} Section Title
</CardTitle>
</CardHeader>
<CardContent className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<InfoItem icon={...} label="Field Name" value={<resource>.field} />
{/* More InfoItems */}
</div>
</CardContent>
</Card>
{/* More Cards for other field groups */}
</div>
)
}
Key rules:
- This is a server component (no
"use client") — it receives data as props - Group related fields into separate
Cardsections - Use the
InfoItemhelper for consistent icon+label+value layout - Show
"—"dash for missing/null values - Use
Badgefor status-like fields,Separatorbetween groups
Step 4: Create the Layout (Server Component)
Create app/(authenticated)/<section>/<resources>/[id]/layout.tsx:
This is the central orchestrator — it fetches the resource, renders the tabbed shell, and wraps children with the context provider.
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { <Resource>Actions } from '@/modules/<resources>/<resource>-actions'
import { <Resource>Provider } from '@/modules/<resources>/<resource>-context'
import React from 'react'
export default async function layout(props: {
params: Promise<{ id: string }>
children: React.ReactNode
}) {
const { id } = await props.params
const api = await getServerApi()
const <resource> = await api.<resources>.getById(id)
const title = /* Build display title from resource fields */ ''
const <resource>Label = /* Build label for context, e.g. combining key fields */ ''
return (
<>
<<Resource>Provider <resource>={{ id, label: <resource>Label || title }}>
<DashboardDetailsPage
className="p-0 lg:p-0"
avatarSrc={<resource>.data?.image_url || ""}
title={title}
description={/* subtitle text */}
backHref="/<section>/<resources>"
actions={<<Resource>Actions <resource>Id={id} />}
tabs={[
{ href: `/<section>/<resources>/${id}`, label: 'Details' },
{ href: `/<section>/<resources>/${id}/<sub-tab>`, label: '<Sub Tab>' },
// More tabs...
]}
>
{props.children}
</DashboardDetailsPage>
</<Resource>Provider>
</>
)
}
Key rules:
- Server component (no
"use client") — usesawaitfor data fetching - Params are
Promise<{ id: string }>in Next.js 15+ (useawait props.params) - Uses
getServerApi()from@garage/api/serverfor server-side API calls <Resource>Providerwraps the entireDashboardDetailsPageso all tab children can access contextbackHrefpoints to the resource list pagetabs[0].hrefis always the base/<section>/<resources>/${id}(Details tab)className="p-0 lg:p-0"removes default padding — sub-pages handle their own spacing
DashboardDetailsPage props:
| Prop | Type | Purpose |
|---|---|---|
title |
string |
Primary heading in the header |
description |
string? |
Subtitle text below the title |
avatarSrc |
string? |
Avatar image URL |
avatarFallback |
string? |
Fallback text for avatar (e.g. initials) |
icon |
ReactNode? |
Icon instead of avatar |
actions |
ReactNode? |
Action buttons in header (right side) |
backHref |
string? |
Back navigation URL |
tabs |
{ href, label }[]? |
Route-based tab navigation |
className |
string? |
Additional classes for content area |
children |
ReactNode |
Active tab content (Next.js route children) |
Step 5: Create the Details Tab (Default Page)
Create app/(authenticated)/<section>/<resources>/[id]/page.tsx:
import { getServerApi } from '@garage/api/server'
import { <Resource>GeneralInfo } from '@/modules/<resources>/<resource>-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
export default async function page(props: { params: Promise<{ id: string }> }) {
const { id } = await props.params
const api = await getServerApi()
const <resource> = await api.<resources>.getById(id)
if (!<resource>.data) {
return <div className="text-muted-foreground"><Resource> not found.</div>
}
return (
<DashboardPage header={null}>
<<Resource>GeneralInfo <resource>={<resource>.data} />
</DashboardPage>
)
}
Key rules:
- Server component — fetches resource data on the server
- Uses
DashboardPagewrapper withheader={null}(the layout already has the header) - Renders the general-info component with the full resource data
- Shows a fallback message if the resource is not found
Step 6: Create Sub-Tab Pages
Choose the appropriate pattern based on the sub-tab's requirements:
Pattern A: ResourcePage with extraParams (Preferred for CRUD sub-tabs)
Use when the sub-tab needs full CRUD for a related resource filtered by the parent ID.
"use client"
import { use } from "react"
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { <Related>Form } from '@/modules/<related>/<related>-form'
import { <RELATED>_ROUTES } from '@garage/api'
import type { <Related>Client } from '@garage/api'
import { use<Resource> } from '@/modules/<resource>/<resource>-context'
export default function <Resource><Related>Page({ params }: { params: Promise<{ id: string }> }) {
const { id: <resource>Id } = use(params)
const <resource> = use<Resource>()
return (
<ResourcePage<<Related>Client>
toolbar={({ invalidateQuery, selectedItem, closeDialog }) => (
<FormDialog title="<Related>">
{(resourceId) => (
<<Related>Form
resourceId={resourceId}
initialData={{
<resource>: <resource>
? { value: <resource>.id, label: <resource>.label }
: null,
}}
onSuccess={() => {
closeDialog();
invalidateQuery();
}}
/>
)}
</FormDialog>
)}
pageTitle="<Resource> <Related>s"
routeKey={<RELATED>_ROUTES.INDEX}
getClient={(api) => api.<related>s}
extraParams={{ <resource>_id: <resource>Id }}
header={null}
columns={({ actionsColumn }) => [
// Column definitions...
actionsColumn(),
]}
/>
)
}
Key rules:
"use client"— uses hooks (use(),use<Resource>())- Uses
use(params)(React 19) to unwrap the params Promise - Consumes the parent context via
use<Resource>()to pre-populate the form's relation field extraParams={{ <resource>_id: <resource>Id }}filters the list to only show related recordsheader={null}— the layout already provides the header- Pass
initialDatawith the parent resource as a relation field{ value, label }
Pattern B: Manual DataTable (For custom interactions)
Use when the sub-tab needs non-standard behavior (link/unlink, file uploads, custom mutations).
"use client"
import { useParams } from "next/navigation"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import DashboardPage from "@/base/components/layout/dashboard/dashboard-page"
export default function <Resource><Tab>Page() {
const { id: <resource>Id } = useParams<{ id: string }>()
const api = useAuthApi()
const queryClient = useQueryClient()
const queryKey = ["<resource>-<tab>", <resource>Id]
const { data, isLoading } = useQuery({
queryKey,
queryFn: () => api.<resources>.get<Tab>(<resource>Id),
})
// Custom mutations (link, unlink, upload, etc.)
return (
<DashboardPage header={null}>
<DataTable
columns={columns}
data={data?.data ?? []}
isLoading={isLoading}
/>
</DashboardPage>
)
}
Key rules:
- Use
useParams()oruse(params)to get the parent resource ID - Custom query key includes the parent resource ID
- Use
DashboardPage header={null}as wrapper - Implement custom mutations with
useMutation+useQueryClientfor cache invalidation
API Integration
Server-Side (Layout + Details Tab)
import { getServerApi } from '@garage/api/server'
// In an async server component:
const api = await getServerApi()
const resource = await api.<resources>.getById(id)
getServerApi()reads auth cookies server-side — no token passing needed- Returns typed responses based on OpenAPI schema
- Used in
layout.tsxand the defaultpage.tsx(Details tab)
Client-Side (Sub-Tab Pages)
import { useAuthApi } from "@/shared/useApi"
// In a client component:
const api = useAuthApi()
// Then use with React Query:
useQuery({ queryFn: () => api.<resources>.list() })
useAuthApi()returns the API client with the auth token from the store- Always use with React Query (
useQuery,useMutation) for caching and state management ResourcePagehandles this internally — only needed for manual DataTable pages
Client Types
API clients live in packages/api/src/clients/<resource>.ts. Each exports:
<RESOURCE>_ROUTES— route constants (INDEX,BY_ID, etc.)<Resource>Client— class with typed methods
These are re-exported from @garage/api for use in the dashboard.
Component Reusability
Reusable Across Resources
| Component | Location | Reuse Pattern |
|---|---|---|
DashboardDetailsPage |
@/base/components/layout/dashboard |
Used by every [id]/layout.tsx |
DashboardPage |
@/base/components/layout/dashboard |
Used by every tab page.tsx with header={null} |
ResourcePage |
@/shared/data-view/resource-page |
Used by CRUD sub-tabs with extraParams |
FormDialog |
@/shared/components/form-dialog |
Used for create/edit dialogs in sub-tabs |
DataTable |
@/shared/data-view/table-view |
Used for custom sub-tabs (non-CRUD) |
ColumnHeader |
@/shared/data-view/table-view |
Used in all column definitions |
InfoItem pattern |
Copy per resource | Icon+label+value display for general-info |
Resource-Specific (Create Per Resource)
| Component | Location | Purpose |
|---|---|---|
<resource>-context.tsx |
modules/<resource>/ |
Context provider for parent resource identity |
<resource>-actions.tsx |
modules/<resource>/ |
Header dropdown menu (edit/delete) |
<resource>-general-info.tsx |
modules/<resource>/ |
Read-only details display for Details tab |
<resource>-form.tsx |
modules/<resource>/ |
CRUD form (shared with list page creation) |
<resource>.schema.ts |
modules/<resource>/ |
Zod schema (shared with form) |
File Structure Convention
app/(authenticated)/<section>/<resources>/[id]/
├── layout.tsx ← Server: fetch + DashboardDetailsPage + Provider
├── page.tsx ← Server: Details tab (GeneralInfo)
├── <sub-tab-1>/page.tsx ← Client: ResourcePage or manual DataTable
├── <sub-tab-2>/page.tsx ← Client: ResourcePage or manual DataTable
└── ...
modules/<resources>/
├── <resource>.schema.ts ← Zod form schema
├── <resource>-form.tsx ← CRUD form component
├── <resource>-context.tsx ← Context provider (id + label)
├── <resource>-actions.tsx ← Actions dropdown
├── <resource>-general-info.tsx ← Read-only info cards
└── <optional-sub-forms>/ ← e.g. document upload forms
Naming Conventions
| Item | Pattern | Example |
|---|---|---|
| Layout file | app/.../<resources>/[id]/layout.tsx |
vehicles/[id]/layout.tsx |
| Details page | app/.../<resources>/[id]/page.tsx |
vehicles/[id]/page.tsx |
| Sub-tab page | app/.../<resources>/[id]/<tab>/page.tsx |
vehicles/[id]/estimates/page.tsx |
| Context file | modules/<resources>/<resource>-context.tsx |
vehicles/vehicle-context.tsx |
| Context type | <Resource>ContextValue |
VehicleContextValue |
| Provider | <Resource>Provider |
VehicleProvider |
| Hook | use<Resource>() |
useVehicle() |
| Actions file | modules/<resources>/<resource>-actions.tsx |
vehicles/vehicle-actions.tsx |
| Actions component | <Resource>Actions |
VehicleActions |
| General info file | modules/<resources>/<resource>-general-info.tsx |
vehicles/vehicle-general-info.tsx |
| General info component | <Resource>GeneralInfo |
VehicleGeneralInfo |
Imports Cheat Sheet
// Layout
import { DashboardDetailsPage } from '@/base/components/layout/dashboard'
import { getServerApi } from '@garage/api/server'
import { <Resource>Actions } from '@/modules/<resources>/<resource>-actions'
import { <Resource>Provider } from '@/modules/<resources>/<resource>-context'
// Details tab
import { getServerApi } from '@garage/api/server'
import { <Resource>GeneralInfo } from '@/modules/<resources>/<resource>-general-info'
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
// Sub-tab (ResourcePage pattern)
import { ResourcePage } from '@/shared/data-view/resource-page'
import { ColumnHeader } from '@/shared/data-view/table-view'
import FormDialog from '@/shared/components/form-dialog'
import { use<Resource> } from '@/modules/<resources>/<resource>-context'
import type { <Related>Client } from '@garage/api'
import { <RELATED>_ROUTES } from '@garage/api'
// Sub-tab (Manual DataTable pattern)
import { useAuthApi } from "@/shared/useApi"
import { DataTable, ColumnHeader } from "@/shared/data-view/table-view"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import DashboardPage from '@/base/components/layout/dashboard/dashboard-page'
// Context provider
import { createContext, useContext } from "react"
// Actions
import { useAuthApi } from "@/shared/useApi"
import { useRouter } from "next/navigation"
import { Button } from "@/shared/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/components/ui/dropdown-menu"
// General Info
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Separator } from "@/shared/components/ui/separator"