# Approval step

> A sign-off with a stated reason and meaning. Shows who must sign and who has, and gives the one person who can still decide Approve and Reject behind a confirm.

```tsx
"use client"

import * as React from "react"

import type { Approver } from "@/lib/approval"
import { ApprovalStep } from "@/components/ui/approval-step"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"

const MEANING = "Approved for release"

// Fictional data. Nothing is sent anywhere. In a real tool `approvers` comes
// from your server and `onDecide` is a request that the server checks and records.
const START: Approver[] = [
  {
    id: "tomas",
    name: "Tomas Berg",
    role: "Manufacturing",
    decision: {
      outcome: "approved",
      at: "2026-03-11T09:30:00Z",
      meaning: MEANING,
      reason: "Fixture for rev C is ready",
    },
  },
  { id: "priya", name: "Priya Raman", role: "Quality" },
]

const VIEWERS = [
  { id: "priya", label: "Priya, Quality (can sign)" },
  { id: "sam", label: "Sam, requester (cannot sign)" },
]

export function ApprovalStepDemo() {
  const [approvers, setApprovers] = React.useState(START)
  const [viewer, setViewer] = React.useState("priya")
  const [failNext, setFailNext] = React.useState(false)

  return (
    <div className="flex w-full max-w-xl flex-col gap-4">
      <div role="group" aria-label="View as" className="flex flex-wrap gap-2">
        {VIEWERS.map((v) => (
          <Button
            key={v.id}
            size="sm"
            variant={viewer === v.id ? "default" : "outline"}
            aria-pressed={viewer === v.id}
            onClick={() => setViewer(v.id)}
          >
            {v.label}
          </Button>
        ))}
      </div>

      <ApprovalStep
        title="ECR-1042: Replace bracket 14-220 with rev C"
        approvers={approvers}
        policy="all"
        currentUserId={viewer}
        meaning={MEANING}
        rejectMeaning="Not approved for release"
        onDecide={async ({ outcome, reason, meaning }) => {
          await new Promise((r) => setTimeout(r, 700))
          if (failNext) {
            setFailNext(false)
            throw new Error("The server did not respond")
          }
          // The server decides who is allowed and stamps the time. Here we
          // just play that part.
          setApprovers((list) =>
            list.map((a) =>
              a.id === viewer
                ? {
                    ...a,
                    decision: {
                      outcome,
                      reason,
                      meaning,
                      at: new Date().toISOString(),
                    },
                  }
                : a
            )
          )
        }}
      />

      <div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-muted-foreground">
        <label className="flex items-center gap-2">
          <Switch checked={failNext} onCheckedChange={setFailNext} />
          Make the next sign-off fail
        </label>
        <Button
          size="sm"
          variant="ghost"
          onClick={() => {
            setApprovers(START)
            setFailNext(false)
          }}
        >
          Reset demo
        </Button>
      </div>
    </div>
  )
}
```

Use it wherever one person's yes or no, with a reason, decides what happens next: an engineering change released, a nonconformance disposition, a lab result held back, a shift handover signed. It sits in the **Act** stage: the list above it (a data table) says what needs a decision, this is the decision, and what follows is telling people ([`confirm-send`](https://realgood.site/docs/components/confirm-send.md)) and keeping the record (`audit-timeline`).

It shows four things: what is being approved, who has to sign and the rule for how many, each decision with its time, meaning and reason, and, only for a person who can still decide, Approve and Reject. Everyone else gets a plain sentence saying why they cannot, not buttons that do nothing.

> **This is a display and an interaction pattern. It is not access control and
> it is not an electronic-signature system.** Hiding a button stops no one. Your
> server must verify who the user is, that they hold the role, and that it is
> their turn, then store the decision and record an audit event. crisp-ui does
> not make a tool compliant with any regulation, and none of this is legal or
> compliance advice.

## Installation

**Command**

```bash
npx shadcn@latest add https://realgood.site/r/approval-step.json
```

Or, with the [`@crisp` namespace](https://realgood.site/docs/installation.md) set up:

```bash
npx shadcn@latest add @crisp/approval-step
```

This also adds the shadcn `button`, `lucide-react`, and the `approval` helpers (`lib/approval.ts`).

**Manual**

**Step 1.** Copy and paste the following code into your project.

```tsx title="components/ui/approval-step.tsx"
"use client"

import * as React from "react"
import { AlertCircle, CheckCircle2, Clock, Lock, XCircle } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  canDecide,
  describePolicy,
  summarizeApproval,
  uniqueApprovers,
  validateDecision,
  type ApprovalOutcome,
  type ApprovalPolicy,
  type Approver,
} from "@/lib/approval"
import { Button } from "@/components/ui/button"

export interface ApprovalStepDecision {
  outcome: ApprovalOutcome
  /** Trimmed. Absent when empty. */
  reason?: string
  /** The meaning of the signature the person confirmed, e.g. "Approved for release". */
  meaning: string
}

export interface ApprovalStepProps
  extends Omit<React.ComponentProps<"section">, "title" | "children"> {
  /** What is being approved, e.g. "ECR-1042: Replace bracket 14-220 with rev C". */
  title: string
  /** Everyone who signs, with their recorded decision if they have made one. From your server. */
  approvers: Approver[]
  /** How many approvals settle the request. Default "all". */
  policy?: ApprovalPolicy
  /** The signed-in user's id, from your session. Compared with `approvers[].id`. */
  currentUserId?: string
  /** What an approval means, e.g. "Approved for release". Default "Approved". */
  meaning?: string
  /** What a rejection means. Default "Rejected". */
  rejectMeaning?: string
  /** Also ask for a reason when approving. A reason is always asked for when rejecting. */
  requireReason?: boolean
  /** Blocks the actions and says why, e.g. "Waiting for Quality to sign first". */
  blockedReason?: string
  /** Replaces "Only Quality can sign off" for a viewer who is not an approver. */
  readOnlyReason?: string
  /** Formats a decision time. Default: "12 Mar 2026, 14:05 UTC". */
  formatTime?: (iso: string) => string
  /**
   * Called after the person confirms. Throw (or reject) to fail; the person can
   * retry. On success, update `approvers` from your server's response.
   */
  onDecide: (decision: ApprovalStepDecision) => void | Promise<void>
}

const timeFormat = new Intl.DateTimeFormat("en-GB", {
  dateStyle: "medium",
  timeStyle: "short",
  timeZone: "UTC",
})

// A fixed zone so the server and browser render the same text.
function defaultFormatTime(iso: string) {
  const date = new Date(iso)
  return Number.isNaN(date.getTime()) ? iso : `${timeFormat.format(date)} UTC`
}

const textareaClass =
  "flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40"

const STATE_ICON = {
  approved: CheckCircle2,
  rejected: XCircle,
  pending: Clock,
}

function joinWords(words: string[], conjunction: string) {
  if (words.length <= 1) return words.join("")
  return `${words.slice(0, -1).join(", ")} ${conjunction} ${words.at(-1)}`
}

/**
 * A sign-off with a stated reason and meaning: who must sign, who has, and, for
 * the one person who can still decide, Approve and Reject behind a confirm.
 *
 * This only shows and collects. Hiding a button is not access control: your
 * server must verify the user, the role and the order, and record the decision.
 */
function ApprovalStep({
  title,
  approvers,
  policy = "all",
  currentUserId,
  meaning = "Approved",
  rejectMeaning = "Rejected",
  requireReason = false,
  blockedReason,
  readOnlyReason,
  formatTime = defaultFormatTime,
  onDecide,
  className,
  ...props
}: ApprovalStepProps) {
  const [outcome, setOutcome] = React.useState<ApprovalOutcome | null>(null)
  const [reason, setReason] = React.useState("")
  const [fieldError, setFieldError] = React.useState<string | null>(null)
  const [submitting, setSubmitting] = React.useState(false)
  const [failure, setFailure] = React.useState<string | null>(null)
  const [lastRecorded, setRecorded] = React.useState<{
    meaning: string
    key: string
    user?: string
  } | null>(null)

  const ids = {
    title: React.useId(),
    question: React.useId(),
    reason: React.useId(),
    fieldError: React.useId(),
    blocked: React.useId(),
  }
  const confirmRef = React.useRef<HTMLDivElement>(null)
  const questionRef = React.useRef<HTMLParagraphElement>(null)
  const reasonRef = React.useRef<HTMLTextAreaElement>(null)
  const approveRef = React.useRef<HTMLButtonElement>(null)
  const rejectRef = React.useRef<HTMLButtonElement>(null)
  const recordedRef = React.useRef<HTMLParagraphElement>(null)
  // Stops a second click while a decision is in flight (state updates are async).
  const inFlight = React.useRef(false)
  // Set when the person leaves the confirm, so focus can return to its button.
  const restoreFocusTo = React.useRef<ApprovalOutcome | null>(null)
  // Set when the confirm closes with focus inside it, so focus can follow to the result.
  const focusRecorded = React.useRef(false)

  const list = React.useMemo(() => uniqueApprovers(approvers), [approvers])
  const summary = React.useMemo(
    () => summarizeApproval(approvers, policy),
    [approvers, policy]
  )
  const me = currentUserId
    ? list.find((a) => a.id === currentUserId)
    : undefined
  const mayDecide = me ? canDecide(me, currentUserId, summary) : false
  // Changes whenever a decision on the list changes.
  const key = list
    .map((a) => `${a.id}:${a.decision?.outcome ?? ""}:${a.decision?.at ?? ""}`)
    .join("|")
  // Only the viewer who signed sees the result of signing.
  const recorded =
    lastRecorded && lastRecorded.user === currentUserId ? lastRecorded : null
  // Our decision went through but the list has not caught up yet.
  const awaitingList = recorded !== null && recorded.key === key
  const showActions = mayDecide && !awaitingList
  const confirming = outcome !== null && showActions && !blockedReason
  const reasonShown = outcome === "rejected" || requireReason

  // The list or the situation changed under an open confirm (someone else settled
  // it, the viewer changed, an order block appeared): what it says no longer
  // holds, so drop back. Not while a decision is in flight.
  React.useEffect(() => {
    if (!confirming && outcome !== null && !inFlight.current) {
      setOutcome(null)
      setFailure(null)
      setFieldError(null)
    }
  }, [confirming, outcome])

  // Focus follows the swap between the buttons and the confirm. It never steals
  // focus the person has already moved elsewhere.
  React.useEffect(() => {
    if (confirming) {
      questionRef.current?.focus()
    } else if (restoreFocusTo.current) {
      const button =
        restoreFocusTo.current === "approved"
          ? approveRef.current
          : rejectRef.current
      restoreFocusTo.current = null
      if (button && document.activeElement === document.body) button.focus()
    }
  }, [confirming])

  React.useEffect(() => {
    if (recorded && focusRecorded.current) {
      focusRecorded.current = false
      recordedRef.current?.focus()
    }
  }, [recorded])

  function open(next: ApprovalOutcome) {
    setReason("")
    setFailure(null)
    setFieldError(null)
    setOutcome(next)
  }

  function cancel() {
    if (inFlight.current || !outcome) return
    restoreFocusTo.current = outcome
    setOutcome(null)
    setFailure(null)
    setFieldError(null)
  }

  async function confirm() {
    if (inFlight.current || !outcome) return
    const check = validateDecision(
      { outcome, reason: reasonShown ? reason : "" },
      { requireReasonOnApprove: requireReason }
    )
    if (!check.valid) {
      setFieldError(check.error)
      reasonRef.current?.focus()
      return
    }
    const decidedMeaning = outcome === "approved" ? meaning : rejectMeaning
    inFlight.current = true
    setSubmitting(true)
    setFailure(null)
    setFieldError(null)
    setRecorded(null)
    try {
      await onDecide({ outcome, reason: check.reason, meaning: decidedMeaning })
      focusRecorded.current =
        document.activeElement === document.body ||
        Boolean(confirmRef.current?.contains(document.activeElement))
      setRecorded({ meaning: decidedMeaning, key, user: currentUserId })
      setOutcome(null)
    } catch (error) {
      const detail =
        error instanceof Error ? error.message.replace(/[.\s]+$/, "") : ""
      setFailure(detail)
    } finally {
      inFlight.current = false
      setSubmitting(false)
    }
  }

  const StatusIcon = STATE_ICON[summary.status]
  const statusText =
    summary.status === "pending"
      ? summary.needed > 0
        ? `Pending, ${summary.approved} of ${summary.needed} approved`
        : "Pending"
      : summary.status === "approved"
        ? "Approved"
        : "Rejected"

  function readOnlyText() {
    if (summary.status === "approved")
      return "This request is approved. No more sign-offs are needed."
    if (summary.status === "rejected")
      return "This request is rejected. No more sign-offs are accepted."
    if (me?.decision) {
      return `You ${me.decision.outcome === "approved" ? "approved" : "rejected"} this request on ${formatTime(me.decision.at)}.`
    }
    if (readOnlyReason) return readOnlyReason
    const waiting = list.filter((a) => !a.decision)
    if (waiting.length === 0) return "No approvers are set for this request."
    const roles = [...new Set(waiting.flatMap((a) => (a.role ? [a.role] : [])))]
    const who = roles.length > 0 ? roles : waiting.map((a) => a.name)
    return `Only ${joinWords(who, policy === "all" ? "and" : "or")} can sign off.`
  }

  const liveMessage = submitting
    ? "Recording your decision…"
    : recorded
      ? `Recorded: ${recorded.meaning}`
      : ""

  const actionName = outcome === "approved" ? "approval" : "rejection"

  return (
    <section
      aria-labelledby={ids.title}
      data-slot="approval-step"
      className={cn(
        "flex flex-col gap-4 rounded-lg border bg-card p-4 text-card-foreground sm:p-5",
        className
      )}
      {...props}
    >
      <span role="status" className="sr-only">
        {liveMessage}
      </span>

      <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
        <div className="min-w-0">
          <p id={ids.title} className="font-medium">
            {title}
          </p>
          <p className="text-sm text-muted-foreground">
            {describePolicy(list, policy)}
          </p>
        </div>
        <span className="inline-flex items-center gap-1.5 text-sm font-medium">
          <StatusIcon aria-hidden="true" className="size-4" />
          {statusText}
        </span>
      </div>

      <ul className="divide-y rounded-md border">
        {list.map((approver) => {
          const decision = approver.decision
          const state = decision ? decision.outcome : "pending"
          const Icon = STATE_ICON[state]
          return (
            <li
              key={approver.id}
              className="flex flex-col gap-1 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
            >
              <p className="text-sm">
                <span className="font-medium">{approver.name}</span>
                {approver.id === currentUserId && (
                  <span className="text-muted-foreground"> (you)</span>
                )}
                {approver.role && (
                  <span className="text-muted-foreground">
                    {" "}
                    · {approver.role}
                  </span>
                )}
              </p>
              <div className="flex flex-col gap-0.5 text-sm sm:items-end sm:text-right">
                <span className="inline-flex items-center gap-1.5 font-medium">
                  <Icon aria-hidden="true" className="size-4" />
                  {decision
                    ? decision.outcome === "approved"
                      ? "Approved"
                      : "Rejected"
                    : "Waiting"}
                </span>
                {decision && (
                  <>
                    <time
                      dateTime={decision.at}
                      className="text-muted-foreground"
                    >
                      {formatTime(decision.at)}
                    </time>
                    {decision.meaning && (
                      <span className="text-muted-foreground">
                        Meaning: {decision.meaning}
                      </span>
                    )}
                    {decision.reason && (
                      <span className="text-muted-foreground">
                        Reason: {decision.reason}
                      </span>
                    )}
                  </>
                )}
              </div>
            </li>
          )
        })}
      </ul>

      {confirming && outcome ? (
        <div
          ref={confirmRef}
          role="group"
          aria-labelledby={ids.question}
          onKeyDown={(event) => {
            if (event.key === "Escape") cancel()
          }}
          className="flex flex-col gap-3 rounded-md border bg-muted/40 p-3"
        >
          {/* tabIndex -1: focusable by script so the question is read out, but not a tab stop. */}
          <p
            id={ids.question}
            ref={questionRef}
            tabIndex={-1}
            className="text-sm font-medium outline-none"
          >
            {`${outcome === "approved" ? "Approve" : "Reject"} with the meaning “${outcome === "approved" ? meaning : rejectMeaning}”? ${
              reasonShown
                ? "Your name, the time and your reason will be recorded."
                : "Your name and the time will be recorded."
            }`}
          </p>
          {reasonShown && (
            <div className="flex flex-col gap-1.5">
              <label htmlFor={ids.reason} className="text-sm font-medium">
                Reason{" "}
                <span className="font-normal text-muted-foreground">
                  (required)
                </span>
              </label>
              <textarea
                id={ids.reason}
                ref={reasonRef}
                data-slot="textarea"
                rows={3}
                value={reason}
                readOnly={submitting}
                aria-required="true"
                aria-invalid={fieldError ? true : undefined}
                aria-describedby={fieldError ? ids.fieldError : undefined}
                className={textareaClass}
                onChange={(event) => {
                  setReason(event.target.value)
                  setFieldError(null)
                }}
              />
              {fieldError && (
                <p
                  id={ids.fieldError}
                  role="alert"
                  className="text-sm text-destructive"
                >
                  {fieldError}
                </p>
              )}
            </div>
          )}
          {failure !== null && (
            <p
              role="alert"
              className="flex items-start gap-1.5 text-sm text-destructive"
            >
              <AlertCircle
                aria-hidden="true"
                className="mt-0.5 size-4 shrink-0"
              />
              <span>
                Could not record your decision{failure ? `: ${failure}` : ""}.
                Try again, or cancel and check the list.
              </span>
            </p>
          )}
          <div className="flex flex-wrap gap-2">
            {/* aria-disabled, not disabled, while recording: the button keeps focus. */}
            <Button
              aria-disabled={submitting}
              className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
              onClick={confirm}
            >
              {submitting
                ? "Recording…"
                : failure !== null
                  ? "Try again"
                  : `Confirm ${actionName}`}
            </Button>
            <Button
              variant="outline"
              aria-disabled={submitting}
              className="aria-disabled:pointer-events-none aria-disabled:opacity-50"
              onClick={cancel}
            >
              Cancel
            </Button>
          </div>
        </div>
      ) : showActions ? (
        <div className="flex flex-col gap-2">
          <div className="flex flex-wrap gap-2">
            <Button
              ref={approveRef}
              aria-label={`Approve: ${title}`}
              disabled={Boolean(blockedReason)}
              aria-describedby={blockedReason ? ids.blocked : undefined}
              onClick={() => open("approved")}
            >
              Approve
            </Button>
            <Button
              ref={rejectRef}
              variant="outline"
              aria-label={`Reject: ${title}`}
              disabled={Boolean(blockedReason)}
              aria-describedby={blockedReason ? ids.blocked : undefined}
              onClick={() => open("rejected")}
            >
              Reject
            </Button>
          </div>
          {blockedReason && (
            <p
              id={ids.blocked}
              className="flex items-start gap-2 text-sm text-muted-foreground"
            >
              <Lock aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
              {blockedReason}
            </p>
          )}
        </div>
      ) : recorded ? (
        <p
          ref={recordedRef}
          tabIndex={-1}
          className="flex items-start gap-2 text-sm font-medium outline-none"
        >
          <CheckCircle2 aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
          Recorded: {recorded.meaning}
        </p>
      ) : (
        <p className="flex items-start gap-2 text-sm text-muted-foreground">
          <Lock aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
          {readOnlyText()}
        </p>
      )}
    </section>
  )
}

export { ApprovalStep }
```

**Step 2.** And the pure helpers it imports.

```ts title="lib/approval.ts"
/**
 * Pure helpers for the approval-step pattern. No network, no React, so they are
 * safe to unit test and to reuse on a server.
 *
 * These decide what a SCREEN shows and offers. They are not an authority: your
 * server must re-check the user, the role and the order, and record the
 * decision, using its own copy of the data.
 */

export type ApprovalOutcome = "approved" | "rejected"

export interface ApprovalDecision {
  outcome: ApprovalOutcome
  /** ISO 8601 date-time, as recorded by your server. */
  at: string
  /** Why. Required on rejection by default (see `validateDecision`). */
  reason?: string
  /** What the signature meant, e.g. "Approved for release", as recorded. */
  meaning?: string
}

export interface Approver {
  /** Stable id. Compared with the signed-in user's id. */
  id: string
  name: string
  /** The role this person signs for, e.g. "Quality". */
  role?: string
  /** Absent until this person has decided. */
  decision?: ApprovalDecision
}

/**
 * How many approvals settle the request:
 * - `"all"`: every approver must approve.
 * - `"any"`: one approval is enough.
 * - `{ min: n }`: n approvals are enough.
 */
export type ApprovalPolicy = "all" | "any" | { min: number }

export type ApprovalStatus = "pending" | "approved" | "rejected"

export interface ApprovalSummary {
  status: ApprovalStatus
  /** Approvals so far. */
  approved: number
  /** Rejections so far. */
  rejected: number
  /** Approvers who have not decided. */
  waiting: number
  /** Approvals required, after the clamping rules below. */
  needed: number
  /** Ids of the approvers who rejected. Only set when `status` is "rejected". */
  blockedBy?: string[]
}

/**
 * Settle a request from its approvers' decisions.
 *
 * One rule covers every policy. `needed` is the approvals required:
 * "all" is every approver, "any" is 1, `{ min }` is `min` rounded down and
 * clamped to between 1 and the number of approvers.
 *
 * - `approved`: approvals >= needed.
 * - `rejected`: approvals plus approvers still waiting < needed, meaning the
 *   requirement can no longer be reached. So under "all" a single rejection
 *   rejects, under "any" it takes everyone rejecting, and `{ min: 2 }` of 3
 *   rejects on the second rejection. A rejection never undoes approvals that
 *   already met the requirement: approval is checked first.
 * - `pending`: anything else.
 *
 * Edge cases, all failing safe:
 * - No approvers: `pending` with `needed: 0`. An empty list never approves
 *   itself; treat it as a set-up error.
 * - Duplicate ids: the first entry for an id counts, later ones are ignored.
 * - `min` greater than the approvers: clamped to the approvers, so it behaves
 *   like "all". A request is never impossible to settle.
 */
export function summarizeApproval(
  approvers: Approver[],
  policy: ApprovalPolicy
): ApprovalSummary {
  const unique = uniqueApprovers(approvers)
  const total = unique.length
  const approved = unique.filter((a) => a.decision?.outcome === "approved")
  const rejected = unique.filter((a) => a.decision?.outcome === "rejected")
  const waiting = total - approved.length - rejected.length
  const needed = approvalsNeeded(total, policy)

  let status: ApprovalStatus = "pending"
  if (total > 0) {
    if (approved.length >= needed) status = "approved"
    else if (approved.length + waiting < needed) status = "rejected"
  }

  return {
    status,
    approved: approved.length,
    rejected: rejected.length,
    waiting,
    needed,
    ...(status === "rejected" ? { blockedBy: rejected.map((a) => a.id) } : {}),
  }
}

/**
 * Can this approver decide right now? Only the signed-in user's own row, only
 * once, and only while the request is still pending.
 *
 * Note: "pending" can include approvers who are not allowed yet (an order
 * your server enforces). Pass `blockedReason` to the component for that; this
 * function only knows about decisions.
 */
export function canDecide(
  approver: Approver,
  currentUserId: string | null | undefined,
  summary: ApprovalSummary
): boolean {
  return (
    Boolean(currentUserId) &&
    approver.id === currentUserId &&
    !approver.decision &&
    summary.status === "pending"
  )
}

export type DecisionCheck =
  | { valid: true; reason?: string }
  | { valid: false; error: string }

export interface DecisionRules {
  /** A rejection needs a reason. Default `true`. */
  requireReasonOnReject?: boolean
  /** An approval needs a reason. Default `false`. */
  requireReasonOnApprove?: boolean
}

/**
 * Check a decision before it is sent. A reason of only whitespace counts as no
 * reason. On success the reason comes back trimmed, or absent if empty.
 */
export function validateDecision(
  decision: { outcome: ApprovalOutcome; reason?: string },
  {
    requireReasonOnReject = true,
    requireReasonOnApprove = false,
  }: DecisionRules = {}
): DecisionCheck {
  if (decision.outcome !== "approved" && decision.outcome !== "rejected") {
    return { valid: false, error: "Choose approve or reject" }
  }
  const reason = decision.reason?.trim() ?? ""
  if (!reason) {
    if (decision.outcome === "rejected" && requireReasonOnReject) {
      return { valid: false, error: "Add a reason for rejecting" }
    }
    if (decision.outcome === "approved" && requireReasonOnApprove) {
      return { valid: false, error: "Add a reason for approving" }
    }
    return { valid: true }
  }
  return { valid: true, reason }
}

/**
 * The policy as a sentence: "All 3 must approve", "Any 1 of 3 can approve",
 * "2 of 3 must approve". Uses the clamped number, so it matches `needed`.
 */
export function describePolicy(
  approvers: Approver[],
  policy: ApprovalPolicy
): string {
  const total = uniqueApprovers(approvers).length
  if (total === 0) return "No approvers are set"
  const needed = approvalsNeeded(total, policy)
  if (total === 1) return "1 approval is needed"
  if (policy === "any") return `Any 1 of ${total} can approve`
  if (needed === total) return `All ${total} must approve`
  return `${needed} of ${total} must approve`
}

function approvalsNeeded(total: number, policy: ApprovalPolicy): number {
  if (total === 0) return 0
  if (policy === "all") return total
  if (policy === "any") return 1
  const min = Math.floor(policy.min)
  if (!Number.isFinite(min)) return total
  return Math.min(Math.max(min, 1), total)
}

/** The approvers, with later entries for an already-seen id dropped. */
export function uniqueApprovers(approvers: Approver[]): Approver[] {
  const seen = new Set<string>()
  return approvers.filter((a) => {
    if (seen.has(a.id)) return false
    seen.add(a.id)
    return true
  })
}
```

**Step 3.** Add the shadcn `button` and install `lucide-react`. Update the import paths to
match your project setup.

## Usage

```tsx
import { ApprovalStep } from "@/components/ui/approval-step"
```

```tsx
// request comes from your server, fetched with the signed-in user's session
<ApprovalStep
  title={request.title}
  approvers={request.approvers}
  policy="all"
  currentUserId={session.user.id}
  meaning="Approved for release"
  onDecide={async ({ outcome, reason, meaning }) => {
    const res = await fetch(`/api/requests/${request.id}/decisions`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ outcome, reason, meaning }),
    })
    if (!res.ok) throw new Error((await res.json()).message) // shown to the person
    await mutate() // your refetch or cache update. <- required: refetch, so `approvers` carries the new decision
  }}
/>
```

The component owns the confirm step, the reason field and the pending, error and success states. You own the data: `approvers` is whatever your server last said.

> After a successful `onDecide`, update `approvers` from the server (state,
> refetch or cache). Until the new decision appears in the list, the buttons
> stay hidden and the line "Recorded: Approved for release" shows. The component
> never writes a decision into the list itself, because the server decides
> whether it was accepted and what time it was recorded.

## The pattern

1. **Say what the signature means.** `meaning` ("Approved for release") appears in the confirm question, is passed to `onDecide`, and is shown next to each recorded decision. A bare "Approved" says less than what you are agreeing to.
2. **Name, time and meaning stay together.** Each decided row shows the signer's name, their role, the state as text with an icon, the time, the meaning and the reason. State is never colour alone.
3. **Confirm in place.** Approve or Reject opens a short confirm below the list: "Approve with the meaning “Approved for release”? Your name and the time will be recorded." A rejection asks for a reason and will not send without one (a whitespace-only reason counts as none). Set `requireReason` to ask for one on approval too.
4. **One decision, once.** Only the signed-in user's own row can decide, only once, and only while the request is open. A second click while a decision is in flight does nothing.
5. **Say why not.** A viewer who cannot decide sees one sentence: "Only Quality can sign off." or "You approved this request on 11 Mar 2026, 09:30 UTC." or "This request is approved. No more sign-offs are needed." The sentence is built from the data; use `readOnlyReason` when your server knows better ("Your account is view-only").
6. **Blocked is visible too.** If the person is an approver but must wait for someone else (an order your server enforces), pass `blockedReason`. The buttons are disabled and the reason is printed beside them.
7. **Failure can be retried.** If `onDecide` throws, the confirm stays open with the error text and a "Try again" button. The reason the person typed is kept.

## Policy and how a request settles

`policy` is one of `"all"`, `"any"` or `{ min: n }`. `summarizeApproval` turns the approvers' decisions into a status, with one rule for every policy:

- `needed` is how many approvals are required: every approver for `"all"`, 1 for `"any"`, and `n` for `{ min: n }` (rounded down, and held between 1 and the number of approvers).
- **Approved** when approvals reach `needed`. Checked first, so a rejection that comes after the requirement was met does not undo it.
- **Rejected** when approvals plus people still waiting can no longer reach `needed`. So under `"all"` any single rejection rejects, under `"any"` it takes everyone rejecting, and `{ min: 2 }` of 3 rejects on the second rejection. `blockedBy` lists who rejected.
- **Pending** otherwise.

Edge cases fail safe: no approvers is `pending` (an empty list never approves itself, so treat it as a set-up error); a repeated approver id counts once, first entry wins; `min` above the number of approvers behaves like `"all"`. A request that is approved or rejected accepts no more decisions, even if some approvers never answered. There is no separate "veto" mode: use `"all"` when one no must block.

These helpers run anywhere, so your server can use the same `summarizeApproval` to decide whether to accept a decision, with its own copy of the data.

## Notes

- **Time is shown in UTC** by default ("11 Mar 2026, 09:30 UTC") so the server and browser render the same text. Pass `formatTime` for another zone or locale. The time is what your server stored in `decision.at`; the browser never makes one up.
- **`meaning` per decision.** Store the meaning the person confirmed with the decision (`decision.meaning`) and send it back. The `meaning` prop is only what the buttons offer; recorded rows show what was recorded.

## Accessibility

- Approve and Reject are real buttons named "Approve: ECR-1042 …" and "Reject: ECR-1042 …", so several steps on a page stay distinguishable.
- Opening the confirm moves focus to its question, which is read out. Cancel, or Escape, returns focus to the button that opened it. Focus is not taken if the person has already moved on.
- The reason field has a visible label and is marked required. A missing reason is announced as an alert and focus goes to the field.
- While a decision is recorded the Confirm button stays focusable (it is `aria-disabled`, not `disabled`) so focus is not lost. A live region announces "Recording your decision…" and then "Recorded: …".
- State is text plus an icon. Icons are hidden from assistive technology because the text says the same.

This has not been tested with a screen reader. Run your own check before relying on it.

## What it does not do

- **It is not access control and not an electronic-signature system.** Hiding a button stops no one. Your server must verify who the user is, that they hold the role and that it is their turn, then store the decision.
- **It does not record anything.** Each decision must be something your server records as an audit event. The UI shows it and does not keep it.
- **It is not a router.** It does not move a request to the next approver, send mail or write the audit log. Those are your server's. When the request settles, notify people with [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
- **It is not a four-way review.** It is approve or reject. A review with more outcomes (approved, approved as noted, revise and resubmit, rejected) is not covered.
- **It handles one request per component.** Render one per request. It makes no compliance claim and gives no legal advice.

## Props

### ApprovalStep

Also accepts the props of a `section` (except `title`, which is the request's), including `className` and `ref`. The prop types are exported as `ApprovalStepProps`; `ApprovalStepDecision` is what `onDecide` receives.

| Prop             | Type                                                        | Description                                                                                |
| ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `title`          | `string`                                                    | What is being approved. Names the section and the buttons. Required.                       |
| `approvers`      | `Approver[]`                                                | Everyone who signs, with their recorded `decision` if any. From your server. Required.     |
| `onDecide`       | `(decision: ApprovalStepDecision) => void \| Promise<void>` | Called after the person confirms. Throw to fail. Update `approvers` on success. Required.  |
| `policy`         | `"all" \| "any" \| { min: number }`                         | How many approvals settle it. Default `"all"`.                                             |
| `currentUserId`  | `string`                                                    | The signed-in user's id, matched against `approvers[].id`. Without it nobody can decide.   |
| `meaning`        | `string`                                                    | What an approval means. Default "Approved".                                                |
| `rejectMeaning`  | `string`                                                    | What a rejection means. Default "Rejected".                                                |
| `requireReason`  | `boolean`                                                   | Also ask for a reason on approval. A reason is always asked on rejection. Default `false`. |
| `blockedReason`  | `string`                                                    | Disables Approve and Reject for an approver who must wait, and shows why.                  |
| `readOnlyReason` | `string`                                                    | Replaces "Only Quality can sign off" for a viewer who is not an approver.                  |
| `formatTime`     | `(iso: string) => string`                                   | Formats `decision.at`. Default "11 Mar 2026, 09:30 UTC".                                   |

`onDecide` receives `{ outcome: "approved" | "rejected", reason?: string, meaning: string }`. The reason is trimmed and absent when empty.

### Helpers (`lib/approval.ts`)

No React and no network, so they are safe on a server.

| Export                                         | What it does                                                                                                                        |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `summarizeApproval(approvers, policy)`         | `{ status, approved, rejected, waiting, needed, blockedBy? }`. Rules above.                                                         |
| `canDecide(approver, currentUserId, summary)`  | True only for the signed-in user's own row, with no decision yet, while the request is pending.                                     |
| `validateDecision({ outcome, reason }, rules)` | `{ valid: true, reason? }` or `{ valid: false, error }`. `rules`: `requireReasonOnReject` (default true), `requireReasonOnApprove`. |
| `describePolicy(approvers, policy)`            | "2 of 3 must approve", "All 3 must approve", "Any 1 of 3 can approve".                                                              |
| `uniqueApprovers(approvers)`                   | The list with repeated ids dropped, first entry wins.                                                                               |

## Agent prompt

Paste this into your coding agent. Replace the bracketed part with your own request and approvers.

```text
Goal: add an approval step to my internal tool. [Describe: what is approved, who must approve, which roles, how many.] The signed-in approver can approve or reject with a reason; everyone else sees why they cannot.

Install:
npx shadcn@latest add https://realgood.site/r/approval-step.json
Read the installed files (components/ui/approval-step.tsx, lib/approval.ts) before writing any code. Do not guess props, and do not rewrite them.

Props of ApprovalStep:
- title: string; approvers: { id, name, role?, decision?: { outcome: "approved"|"rejected", at: ISO string, reason?, meaning? } }[]
- policy: "all" | "any" | { min: number } (default "all")
- currentUserId: the signed-in user's id from the session
- meaning: string, e.g. "Approved for release"; rejectMeaning; requireReason; blockedReason; readOnlyReason
- onDecide({ outcome, reason, meaning }): Promise<void>. Throw to fail.

Wiring rule: the UI only displays and collects. Hiding a button is not access control. Add a server endpoint that checks who the user is, that they hold the role and it is their turn, refuses a second decision, stamps the time itself, saves the decision with its reason and meaning, and writes one audit event (who, what, when, meaning, reason). onDecide calls it and throws on any non-2xx. On success, refetch the request and pass the server's approvers back in. Take approvers, decisions and times only from the server; never work out permissions in the browser.

States to cover: waiting; can decide; confirm open (reason required on reject); recording; failed with retry; recorded; settled; viewer cannot decide (read-only sentence, not hidden).

Acceptance checks, run them and show me the output:
1. typecheck and lint pass.
2. A non-approver sees "Only <role> can sign off." and no buttons.
3. Rejecting with an empty or whitespace-only reason is refused.
4. The server, not the UI, refuses a second decision from the same person and a decision from the wrong role.
5. Each accepted decision creates exactly one audit event.
6. Keyboard: Tab reaches Approve and Reject; Cancel returns focus to the button.

Do not add libraries or a signature/identity feature. This is not a compliant e-signature system. Build only what I asked. If something is unclear, ask me.
```

## Examples by sector

Fictional, and not legal or compliance advice. The regulatory points are UI cues read from the rules' public text, not a determination that a tool is compliant: for example, 21 CFR 11.50 expects a signed record to show the signer's name, the date and time, and the meaning of the signature, and ISO 9001 clause 7.5.3 separates permission to view from permission to change.

- **Education.** The research behind these patterns found few approval flows in education (none in the workflows coded), so there is no education example here. A grade change or a trip permission could use the same step; the page does not claim more than that.
- **Manufacturing.** Nonconformance (NCR) disposition. Approvers: Quality engineer (`role: "Quality"`) and Production supervisor (`role: "Manufacturing"`), policy `"all"`. `meaning`: "Disposition approved: rework". `rejectMeaning`: "Disposition not approved". Reason required on reject. The proposed disposition (use as is, rework, scrap) is part of the request title or body, not of this component. Lock the record once settled.
- **Engineering.** Engineering change order (ECO) release. Approvers: Quality, Manufacturing, Engineering manager. Policy `"all"`, or `{ min: 2 }` if your process allows it. `meaning`: "Approved for release". Set `requireReason` so approvals carry a note too. When it settles, notify the people who build from the drawing with [`confirm-send`](https://realgood.site/docs/components/confirm-send.md).
- **Health.** Lab-result hold. Approver: Laboratory director (`role: "Laboratory"`), policy `"any"` with a second named reviewer as cover. `meaning`: "Release hold approved". `requireReason`: on, because each hold is documented individually, not as a blanket rule. **Keep patient details out of the notification text**: "A result needs review", never a name, test or value. The record stays in your system behind its own role checks.

## Next

Comes after: [`data-table`](https://realgood.site/docs/components/data-table.md). Leads to: [`confirm-send`](https://realgood.site/docs/components/confirm-send.md), [`audit-timeline`](https://realgood.site/docs/components/audit-timeline.md).
