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"
}