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