--- name: resource-details-page description: "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//-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). ```tsx "use client" import { createContext, useContext } from "react" type ContextValue = { id: string label: string } const Context = createContext<ContextValue | null>(null) export function Provider({ , children, }: { : ContextValue children: React.ReactNode }) { return ( <Context.Provider value={}> {children} Context.Provider> ) } export function use() { return useContext(Context) } ``` **Key rules:** - Always a `"use client"` component (context requires client React) - Keep the context value minimal — only `id` and `label` - `label` is a human-readable display string built from the resource's key fields - The hook returns `null` when 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//-actions.tsx`: This is a dropdown menu rendered in the `DashboardDetailsPage` header for resource-level actions. ```tsx "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 ActionsProps = { Id: string } export function Actions({ Id }: ActionsProps) { const api = useAuthApi() const router = useRouter() const handleEdit = () => { router.push(`/
//${Id}/edit`) } const handleDelete = async () => { await api..destroy(Id) router.push("/
/") } return ( Edit Delete ) } ``` **Key rules:** - Always a `"use client"` component - Accepts `Id: string` as the only required prop - Uses `useAuthApi()` for API calls, `useRouter()` for navigation - `handleDelete` navigates back to the list page after deletion - Add confirmation dialog for destructive actions when appropriate ### Step 3: Create the General Info Component Create `modules//-general-info.tsx`: A read-only display component for the **Details tab** (the default `page.tsx`). Uses Card-based layout with icon-labeled fields. ```tsx 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 Data = { // Type all fields from the API response id?: number name?: string // ... } type GeneralInfoProps = { : Data } function InfoItem({ icon: Icon, label, value, }: { icon: React.ComponentType<{ className?: string }> label: string value?: string | null }) { return (
{label} {value || }
) } export function GeneralInfo({ }: GeneralInfoProps) { return (
{/* Icon */} Section Title
.field} /> {/* More InfoItems */}
{/* More Cards for other field groups */}
) } ``` **Key rules:** - This is a **server component** (no `"use client"`) — it receives data as props - Group related fields into separate `Card` sections - Use the `InfoItem` helper for consistent icon+label+value layout - Show `"—"` dash for missing/null values - Use `Badge` for status-like fields, `Separator` between groups ### Step 4: Create the Layout (Server Component) Create `app/(authenticated)/
//[id]/layout.tsx`: This is the **central orchestrator** — it fetches the resource, renders the tabbed shell, and wraps children with the context provider. ```tsx import { DashboardDetailsPage } from '@/base/components/layout/dashboard' import { getServerApi } from '@garage/api/server' import { Actions } from '@/modules//-actions' import { Provider } from '@/modules//-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 = await api..getById(id) const title = /* Build display title from resource fields */ '' const Label = /* Build label for context, e.g. combining key fields */ '' return ( <> <Provider ={{ id, label: Label || title }}> .data?.image_url || ""} title={title} description={/* subtitle text */} backHref="/
/" actions={<Actions Id={id} />} tabs={[ { href: `/
//${id}`, label: 'Details' }, { href: `/
//${id}/`, label: '' }, // More tabs... ]} > {props.children} Provider> ) } ``` **Key rules:** - **Server component** (no `"use client"`) — uses `await` for data fetching - Params are `Promise<{ id: string }>` in Next.js 15+ (use `await props.params`) - Uses `getServerApi()` from `@garage/api/server` for server-side API calls - `Provider` wraps the entire `DashboardDetailsPage` so all tab children can access context - `backHref` points to the resource list page - `tabs[0].href` is always the base `/
//${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)/
//[id]/page.tsx`: ```tsx import { getServerApi } from '@garage/api/server' import { GeneralInfo } from '@/modules//-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 = await api..getById(id) if (!.data) { return
not found.
} return ( <GeneralInfo ={.data} /> ) } ``` **Key rules:** - **Server component** — fetches resource data on the server - Uses `DashboardPage` wrapper with `header={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. ```tsx "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 { Form } from '@/modules//-form' import { _ROUTES } from '@garage/api' import type { Client } from '@garage/api' import { use } from '@/modules//-context' export default function Page({ params }: { params: Promise<{ id: string }> }) { const { id: Id } = use(params) const = use() return ( Client> toolbar={({ invalidateQuery, selectedItem, closeDialog }) => ( {(resourceId) => ( <Form resourceId={resourceId} initialData={{ : ? { value: .id, label: .label } : null, }} onSuccess={() => { closeDialog(); invalidateQuery(); }} /> )} )} pageTitle=" s" routeKey={_ROUTES.INDEX} getClient={(api) => api.s} extraParams={{ _id: Id }} header={null} columns={({ actionsColumn }) => [ // Column definitions... actionsColumn(), ]} /> ) } ``` **Key rules:** - `"use client"` — uses hooks (`use()`, `use()`) - Uses `use(params)` (React 19) to unwrap the params Promise - Consumes the parent context via `use()` to pre-populate the form's relation field - `extraParams={{ _id: Id }}` filters the list to only show related records - `header={null}` — the layout already provides the header - Pass `initialData` with 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). ```tsx "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 Page() { const { id: Id } = useParams<{ id: string }>() const api = useAuthApi() const queryClient = useQueryClient() const queryKey = ["-", Id] const { data, isLoading } = useQuery({ queryKey, queryFn: () => api..get(Id), }) // Custom mutations (link, unlink, upload, etc.) return ( ) } ``` **Key rules:** - Use `useParams()` or `use(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` + `useQueryClient` for cache invalidation ## API Integration ### Server-Side (Layout + Details Tab) ```tsx import { getServerApi } from '@garage/api/server' // In an async server component: const api = await getServerApi() const resource = await api..getById(id) ``` - `getServerApi()` reads auth cookies server-side — no token passing needed - Returns typed responses based on OpenAPI schema - Used in `layout.tsx` and the default `page.tsx` (Details tab) ### Client-Side (Sub-Tab Pages) ```tsx import { useAuthApi } from "@/shared/useApi" // In a client component: const api = useAuthApi() // Then use with React Query: useQuery({ queryFn: () => api..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 - `ResourcePage` handles this internally — only needed for manual DataTable pages ### Client Types API clients live in `packages/api/src/clients/.ts`. Each exports: - `_ROUTES` — route constants (`INDEX`, `BY_ID`, etc.) - `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 | |---|---|---| | `-context.tsx` | `modules//` | Context provider for parent resource identity | | `-actions.tsx` | `modules//` | Header dropdown menu (edit/delete) | | `-general-info.tsx` | `modules//` | Read-only details display for Details tab | | `-form.tsx` | `modules//` | CRUD form (shared with list page creation) | | `.schema.ts` | `modules//` | Zod schema (shared with form) | ## File Structure Convention ``` app/(authenticated)/
//[id]/ ├── layout.tsx ← Server: fetch + DashboardDetailsPage + Provider ├── page.tsx ← Server: Details tab (GeneralInfo) ├── /page.tsx ← Client: ResourcePage or manual DataTable ├── /page.tsx ← Client: ResourcePage or manual DataTable └── ... modules// ├── .schema.ts ← Zod form schema ├── -form.tsx ← CRUD form component ├── -context.tsx ← Context provider (id + label) ├── -actions.tsx ← Actions dropdown ├── -general-info.tsx ← Read-only info cards └── / ← e.g. document upload forms ``` ## Naming Conventions | Item | Pattern | Example | |---|---|---| | Layout file | `app/...//[id]/layout.tsx` | `vehicles/[id]/layout.tsx` | | Details page | `app/...//[id]/page.tsx` | `vehicles/[id]/page.tsx` | | Sub-tab page | `app/...//[id]//page.tsx` | `vehicles/[id]/estimates/page.tsx` | | Context file | `modules//-context.tsx` | `vehicles/vehicle-context.tsx` | | Context type | `ContextValue` | `VehicleContextValue` | | Provider | `Provider` | `VehicleProvider` | | Hook | `use()` | `useVehicle()` | | Actions file | `modules//-actions.tsx` | `vehicles/vehicle-actions.tsx` | | Actions component | `Actions` | `VehicleActions` | | General info file | `modules//-general-info.tsx` | `vehicles/vehicle-general-info.tsx` | | General info component | `GeneralInfo` | `VehicleGeneralInfo` | ## Imports Cheat Sheet ```tsx // Layout import { DashboardDetailsPage } from '@/base/components/layout/dashboard' import { getServerApi } from '@garage/api/server' import { Actions } from '@/modules//-actions' import { Provider } from '@/modules//-context' // Details tab import { getServerApi } from '@garage/api/server' import { GeneralInfo } from '@/modules//-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 } from '@/modules//-context' import type { Client } from '@garage/api' import { _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" ```