# Pattern — `{Entity}DetailPage.tsx`

## When to use

The page that renders a single entity's full state (read view). URL contains
a `:id` param. Has an "Edit" CTA that navigates to the Form page. May expose
a sidebar slot for related data (activity timeline, relations tab, etc.).

## Anatomy

```
PermissionGuard (read)
  PageTemplate (title = entity label, icon, actions = Edit CTA)
    Slot             — {entity}.detail.header (context={data})
    body             — SectionCard × N when pageSpec sections resolve (title =
                       the SHARED form.section.<camel> key), each holding a dl
                       grid of its fields; otherwise the legacy flat card with
                       one dl grid over every field
    Slot             — {entity}.detail.sidebar (context={data})
```

## Canonical example (compliant)

```tsx
import { useParams, useNavigate, Navigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, Pencil, FileText } from 'lucide-react';
import { PermissionGuard, Slot } from '@atlashub/smartstack';
import { PageTemplate } from '@/components/ui/PageTemplate';
import { useContact } from '../hooks/useContact';

export function ContactDetailPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const { t } = useTranslation('contact');

  if (!id) return <Navigate to=".." replace />;  // R9 — null-check

  const { data, isLoading } = useContact(id);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
        <Loader2 className="w-5 h-5 animate-spin mr-2" />
        {t('detail.loading')}
      </div>
    );
  }
  if (!data) {
    return (
      <div className="p-4 rounded-[var(--radius-card)] bg-[var(--warning-bg)] border border-[var(--warning-border)] text-[var(--warning-text)]">
        {t('detail.notFound')}
      </div>
    );
  }

  const headerTitle = `${data.firstName ?? ''} ${data.lastName}`.trim();

  return (
    <PermissionGuard permission="crm.contacts.read">
      <PageTemplate
        title={headerTitle}
        icon={<FileText className="w-6 h-6" />}
        actions={
          <PermissionGuard permission="crm.contacts.update">
            <button
              type="button"
              onClick={() => navigate('./edit')}
              className="flex items-center gap-1.5 px-3 py-2 rounded-[var(--radius-button)] bg-[var(--color-accent-500)] text-white text-sm font-medium hover:bg-[var(--color-accent-600)] transition-colors"
            >
              <Pencil className="w-4 h-4" />
              {t('detail.edit')}
            </button>
          </PermissionGuard>
        }
      >
        <Slot name="contact.detail.header" context={{ data }} />

        <div className="p-6 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
          <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
            <div>
              <dt className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1">
                {t('detail.fields.lastName')}
              </dt>
              <dd className="text-sm text-[var(--text-primary)]">{data.lastName}</dd>
            </div>
            <div>
              <dt className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-1">
                {t('detail.fields.email')}
              </dt>
              <dd className="text-sm text-[var(--text-primary)]">{data.email ?? '—'}</dd>
            </div>
            {/* one <div> per field */}
          </dl>
        </div>

        <Slot name="contact.detail.sidebar" context={{ data }} />
      </PageTemplate>
    </PermissionGuard>
  );
}
```

## Sectioned body (`pageSpec.sections[]`)

When the pageSpec resolves sections (first-order `sections[]` — `{key,
labelKey?, label?, description?, columns?, fields[]}` — or per-field `section`
values), the flat card above is replaced by **one `<SectionCard>` per
section** (`@/components/ui/SectionCard`, emitted by scaffold-ui-primitives),
each holding the same `<dl>` grid restricted to its fields:

```tsx
<SectionCard title={t('form.section.identity', { defaultValue: 'Identity' })}>
  <dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
    {/* dt/dd per field of the section */}
  </dl>
</SectionCard>
```

Section labels are **SHARED with the form** (`form.section.<camel>`) — never
mint a `detail.section.*` namespace. When the page has `tabs[]`, section
membership renders INSIDE each tab panel as flat `<h3>` sub-group headers
over their own `<dl>` — the panel is already the card, never nest a
SectionCard there. A spec with no resolvable sections keeps the legacy flat
`<dl>` card exactly as in the example. relatedTabs (360) and the header /
custom actions are untouched by sectioning.

A related tab whose target `(relatedApp ?? the page's app, relatedModule)`
differs from the page's own is rendered under TWO gates, not one: the usual
`<PermissionGuard>` (may this actor read it?) and the tenant catalogue (was this
module delivered to this client at all?) —
`const show{Entity}Related{Key} = moduleAvailability.hasModule('<app>', '<module>')`
from `@/components/ui/useModuleAvailability`, applied to the trigger (or the band
cartouche) AND the panel. Its imports also follow the TARGET's application
(`@/features/<relatedApp>/<relatedModule>/…`), never the page's. Same-module tabs
are untouched — the page itself would be unreachable if its own module were
missing. The rule is derived, never authored: `lib/page-spec-related-tabs`
`requiresAvailabilityGuard` states it once, for the emitter and for DEV-UI-049.

## Rules this pattern enforces

- **R1** — `<PageTemplate>`
- **R3** — primary CTA Edit → `bg-[var(--color-accent-500)]`
- **R4** — `useTranslation('entity')` + `t('detail.fields.<name>')` for every field label
- **R5** — lucide-react icons only
- **R6** — `<Loader2 className="animate-spin" />` for loading
- **R9** — `useParams<{ id: string }>()` is null-checked (`if (!id) return <Navigate …>`)
- **R10** — permission key format `{module}.{section}.{action}`
- **R11** — two Slot extension points: `{entity}.detail.header` and `{entity}.detail.sidebar`
- **R16** — radius tokens (`rounded-[var(--radius-card|button)]`)
- **R19** — no palette / absolute color classes
- **R20** — border always paired with `border-[var(--border-color)]`

## Import contract

| Name | From |
|---|---|
| `PermissionGuard`, `Slot` | `@atlashub/smartstack` |
| `formatDate`, `formatDateTime` (when date fields exist) | `@atlashub/smartstack` |
| `PageTemplate` | `@/components/ui/PageTemplate` |
| `SectionCard` (sectioned body, no tabs) | `@/components/ui/SectionCard` |
| Icons | `lucide-react` |
| `useTranslation` | `react-i18next` |
| `useParams`, `useNavigate`, `Navigate` | `react-router-dom` |

**Date fields** — a `date` field renders `{formatDate(data.x)}`, a `datetime` field
`{formatDateTime(data.x)}` (platform display settings; date-only values are rendered
in UTC so the calendar day never shifts). Related-tab (360) columns follow their
pagespec `formatHint`, falling back to the key-suffix convention
(`…At` → date+time, `…Date|On|Until` → date).

## Do NOT

- Skip the `if (!id) return <Navigate …>` guard — breaks R9 + crashes when route
  lands on `/entity/` without id.
- Render fields via hardcoded labels — every field label must be `t('detail.fields.<key>')`.
- Put the Edit button outside `<PermissionGuard permission="*.update">`.
- Render `data.lastName` as a raw string unprotected — always fall back via
  `data.email ?? '—'` for nullable fields to avoid "undefined" in the UI.
- Use `bg-white` for the card → `bg-[var(--bg-card)]`.
- Mint `detail.section.*` i18n keys — section titles reuse the form's
  `form.section.<camel>` keys.
- Nest a `<SectionCard>` inside a tab panel — sub-groups there are flat `<h3>`
  headers; the panel is the card.
