dodil/erp-ui

public/r/detail-page.json

{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "detail-page",
  "title": "DetailPage",
  "description": "One record: header, status, actions that say why they are unavailable, sections, other modules' panels.",
  "dependencies": [
    "@radix-ui/themes"
  ],
  "registryDependencies": [
    "@erp-ui/format",
    "@erp-ui/status-badge"
  ],
  "files": [
    {
      "path": "src/components/detail-page/DetailPage.tsx",
      "content": "/**\n * DetailPage — one record: its header (name, status, actions), its sections of fields, and the panels\n * other modules add through an extension point (the UI's seams).\n *\n * An action the caller cannot take is shown DISABLED WITH THE REASON rather than hidden — the person sees\n * that the step exists and why it is not available (\"needs the deal desk's approval\"). A record the caller\n * may not see is \"not found\", the same answer the API gives (a 404, never a 403).\n */\nimport { Box, Button, Card, Flex, Grid, Heading, Spinner, Text, Tooltip } from \"@radix-ui/themes\";\nimport type { ReactNode } from \"react\";\n\nimport { formatAmount, formatDate, formatDateTime } from \"../../lib/format\";\nimport { StatusBadge } from \"../status-badge/StatusBadge\";\n\nexport interface Field {\n  label: string;\n  value: unknown;\n  kind?: \"text\" | \"money\" | \"date\" | \"datetime\" | \"status\";\n  currency?: string;\n}\n\nexport interface Section {\n  title: string;\n  fields?: Field[];\n  content?: ReactNode;\n}\n\nexport interface Action {\n  label: string;\n  onClick: () => void;\n  /** When set, the action is shown disabled with this as the reason. */\n  disabledReason?: string;\n  tone?: \"primary\" | \"neutral\" | \"danger\";\n  busy?: boolean;\n}\n\nexport interface DetailPageProps {\n  title?: string;\n  subtitle?: string;\n  status?: string;\n  actions?: Action[];\n  sections?: Section[];\n  /** Controls beside the actions that are more than a click — a dialog's trigger, a menu. */\n  toolbar?: ReactNode;\n  /** Panels from other modules, already resolved by the shell for this extension point. */\n  panels?: { title: string; content: ReactNode }[];\n  loading?: boolean;\n  notFound?: boolean;\n  error?: string | null;\n}\n\nfunction show(f: Field): ReactNode {\n  if (f.value === null || f.value === undefined || f.value === \"\") return \"—\";\n  switch (f.kind) {\n    case \"money\": return `${formatAmount(f.value as string)}${f.currency ? ` ${f.currency}` : \"\"}`;\n    case \"date\": return formatDate(String(f.value));\n    case \"datetime\": return formatDateTime(String(f.value));\n    case \"status\": return <StatusBadge status={String(f.value)} />;\n    default: return String(f.value);\n  }\n}\n\nconst COLOR = { primary: undefined, neutral: \"gray\", danger: \"red\" } as const;\n\n/**\n * One action button. With `disabledReason` it is disabled and SAYS WHY — in a tooltip, and to a screen\n * reader through aria-description — rather than vanishing. Use it anywhere a step may be unavailable.\n */\nexport function ActionButton({ label, onClick, disabledReason, tone, busy }: Omit<Action, \"onClick\"> & { onClick?: () => void }) {\n  const button = (\n    <Button\n      variant={tone === \"primary\" ? \"solid\" : \"soft\"}\n      color={COLOR[tone ?? \"neutral\"]}\n      onClick={onClick}\n      disabled={!!disabledReason || busy}\n      aria-description={disabledReason}\n    >\n      {busy && <Spinner />} {label}\n    </Button>\n  );\n  return disabledReason ? <Tooltip content={disabledReason}><span tabIndex={0}>{button}</span></Tooltip> : button;\n}\n\nexport function DetailPage({ title, subtitle, status, actions = [], toolbar, sections = [], panels = [], loading, notFound, error }: DetailPageProps) {\n  if (loading) return <Flex gap=\"2\" align=\"center\" p=\"4\"><Spinner /><Text color=\"gray\">Loading…</Text></Flex>;\n  if (notFound) return <Box p=\"4\"><Heading size=\"5\">Not found</Heading><Text color=\"gray\">It does not exist, or you cannot see it.</Text></Box>;\n  if (error) return <Text as=\"p\" color=\"red\" role=\"alert\">{error}</Text>;\n  return (\n    <Flex direction=\"column\" gap=\"4\">\n      <Flex align=\"start\" justify=\"between\" gap=\"4\" wrap=\"wrap\">\n        <Box>\n          <Flex align=\"center\" gap=\"3\">\n            <Heading as=\"h1\" size=\"6\">{title}</Heading>\n            {status && <StatusBadge status={status} />}\n          </Flex>\n          {subtitle && <Text as=\"p\" color=\"gray\" size=\"2\">{subtitle}</Text>}\n        </Box>\n        <Flex gap=\"2\" wrap=\"wrap\">\n          {actions.map((a) => <ActionButton key={a.label} {...a} />)}\n          {toolbar}\n        </Flex>\n      </Flex>\n      {sections.map((s) => (\n        <Card key={s.title} asChild>\n          <section aria-label={s.title}>\n            <Heading as=\"h2\" size=\"3\" mb=\"3\">{s.title}</Heading>\n            {s.fields && (\n              <Grid columns={{ initial: \"1\", sm: \"2\", md: \"3\" }} gap=\"3\" asChild>\n                <dl style={{ margin: 0 }}>\n                  {s.fields.map((f) => (\n                    <Box key={f.label}>\n                      <Text as=\"div\" size=\"1\" color=\"gray\" asChild><dt>{f.label}</dt></Text>\n                      <Text as=\"div\" size=\"2\" asChild><dd style={{ margin: 0 }}>{show(f)}</dd></Text>\n                    </Box>\n                  ))}\n                </dl>\n              </Grid>\n            )}\n            {s.content}\n          </section>\n        </Card>\n      ))}\n      {panels.map((p) => (\n        <Card key={p.title} asChild>\n          <section aria-label={p.title}>\n            <Heading as=\"h2\" size=\"3\" mb=\"3\">{p.title}</Heading>\n            {p.content}\n          </section>\n        </Card>\n      ))}\n    </Flex>\n  );\n}\n",
      "type": "registry:component",
      "target": "erp-ui/components/detail-page/DetailPage.tsx"
    }
  ],
  "type": "registry:component"
}