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>
  );
}