# Purchase Order

A purchase order: the artifact with its React layer, the composition, and a sample order that runs to three pages.

Canonical URL: https://docs.asterdoc.com/components/blocks/purchase-order/

Unlike the invoice, a purchase order is an agreement between two parties, so
its composition closes with a signature block for each of them. Its sample
data is deliberately long — 32 rows, several times what one page holds — so
installing it shows the table continuing across more than one break, header
repeated, rather than a document that happens to spill once.

## Preview


```tsx
import { Pages } from "@/components/asterdoc/pages";
import { purchaseOrderData } from "@/artifacts/asterdoc/purchase-order.data";
import {
  PurchaseOrderDocument,
  purchaseOrderFurniture,
} from "@/components/asterdoc/purchase-order";

export function PurchaseOrderBlockPreview() {
  return (
    <Pages furniture={purchaseOrderFurniture}>
      <PurchaseOrderDocument data={purchaseOrderData} />
    </Pages>
  );
}
```


## Installation


```bash
npx shadcn@4 add @asterdoc/purchase-order
```


```bash
asterdoc add purchase-order
```


Installs to artifacts/asterdoc/purchase-order.artifact.ts, artifacts/asterdoc/purchase-order.data.ts, components/asterdoc/purchase-order.tsx.


## Usage

A block has no single call site — it is a whole document — so this shows
the composition and its sample data exactly as installing `purchase-order`
gives them to you, both real and currently shipping.


**Composition**

```tsx
/** @jsxRuntime classic */
import React from "react";
import type { Form } from "@asterdoc/types";

import { Document } from "@/components/asterdoc/document";
import {
  markDocumentRoot,
  type DocumentData,
  type DocumentTokensInput,
  type FormatOptions,
  type PageFurniture,
} from "@asterdoc/react";
import { Field } from "@/components/asterdoc/field";
import { PageNumber } from "@/components/asterdoc/page-number";
import { Section } from "@/components/asterdoc/section";
import { Signature } from "@/components/asterdoc/signature";
import { Table } from "@/components/asterdoc/table";
import { Text } from "@/components/asterdoc/text";
import { Totals } from "@/components/asterdoc/totals";
import { purchaseOrderForm } from "../../artifacts/asterdoc/purchase-order.artifact";

/**
 * The purchase order, composed from the components.
 *
 * One tree, built only from `Document`, `Section`, `Text`, `Field`, `Table`,
 * `Totals`, and `Signature`, that carries no copy of any label, format, or
 * total and sizes none of its own text. It is the same tree the preview
 * paginates and the PDF renders; `purchaseOrderFurniture` numbers its pages in
 * both.
 *
 * The buyer and the supplier are printed from the artifact's own `buyer` and
 * `supplier` fields rather than through `Party`. A session fills this order,
 * and a document mid-session has parties nobody has answered yet: `Field`
 * prints the blank placeholder for those, where `Party` fails on a role with
 * no party filled.
 *
 * Unlike the proposal, this composition does not wrap itself in a `Bundle`.
 * It is used both on its own and as one part of a larger packet, and a
 * packet's own layer supplies the bundle around every composition it
 * contains, so this one renders bare and lets the caller decide.
 */

/**
 * The order's page furniture: the page number and the count, in the footer.
 *
 * Hand the same object to `<Pages furniture>` and to `renderPdf`, so the
 * preview and the PDF number the same pages. It is drawn inside the margin, so
 * the page plan and the page count are what they are without it.
 */
export const purchaseOrderFurniture: PageFurniture = { footer: <PageNumber /> };

export interface PurchaseOrderDocumentProps {
  /** The purchase order data to render. */
  data: DocumentData;
  /** Overrides the artifact, for tests that vary it. */
  artifact?: Form;
  /** How values the serializer registry does not cover are formatted, and which registry (US or EU) covers the rest. */
  format?: FormatOptions;
  /** Tenant branding. See `src/examples/tokens.ts` for the sample's second set. */
  tokens?: DocumentTokensInput;
}

/**
 * The composition's content. Nothing here sizes its own text: the title is a
 * `Text` heading and every value inherits the document's body size, so the
 * whole order follows `typography`.
 */
function PurchaseOrderBody({ artifact }: { artifact: Form }) {
  return (
    <>

      <Section id="masthead" className="flex flex-row justify-between gap-8 border-b border-neutral-800 pb-4">
        <div className="flex basis-1/2 flex-col gap-1">
          <Text keepId="title" role="heading" as="span">
            {artifact.title}
          </Text>
          <Field path="buyer" label={false} />
          <Field path="buyerAddress" label={false} />
        </div>
        <div className="flex basis-1/3 flex-col gap-2">
          <Field path="orderNumber" />
          <Field path="issuedOn" />
          <Field path="deliverBy" />
          <Field path="currency" />
        </div>
      </Section>

      <Section id="supplier" title="Supplier" className="flex flex-col gap-1">
        <Field path="supplier" label={false} className="font-medium" />
        <Field path="supplierContact" label={false} />
        <Field path="supplierAddress" label={false} />
      </Section>

      <Section id="ship-to" title="Ship to" className="flex flex-col gap-1">
        <Field path="shipTo" label={false} />
      </Section>

      <Section id="line-items" title="Ordered items" className="flex flex-col gap-3">
        <Table
          path="lineItems"
          id="line-items"
          columns={[
            { field: "description", width: "basis-1/2" },
            { field: "quantity", header: "Qty", width: "basis-1/12", align: "right" },
            { field: "unit", width: "basis-1/12" },
            { field: "unitPrice", header: "Unit price", width: "basis-1/6", align: "right" },
            { field: "amount", width: "basis-1/6", align: "right" },
          ]}
        />
        <Totals
          rows={[
            { def: "subtotal" },
            { def: "tax", ratePath: "taxRatePercent" },
            { def: "total", emphasis: true },
          ]}
        />
      </Section>

      <Section id="terms" title="Terms">
        <Field path="terms" label={false} />
      </Section>

      <Section id="acceptance" title="Acceptance" className="flex flex-col gap-4 pt-4">
        <div className="flex flex-row gap-10">
          <Signature party="buyer" className="flex basis-1/2 flex-col gap-1" />
          <Signature party="supplier" className="flex basis-1/2 flex-col gap-1" />
        </div>
      </Section>
    </>
  );
}

/**
 * The composed purchase order.
 *
 * Exported by name and as the module's default. The default is what a React
 * layer binds to when the renderer imports the module the layer's path names,
 * which is the convention a composition module follows.
 */
export function PurchaseOrderDocument({
  data,
  artifact = purchaseOrderForm,
  format,
  tokens,
}: PurchaseOrderDocumentProps) {
  return (
    <Document artifact={artifact} data={data} format={format} tokens={tokens} id="purchase-order">
      <PurchaseOrderBody artifact={artifact} />
    </Document>
  );
}

markDocumentRoot(PurchaseOrderDocument);
export default PurchaseOrderDocument;
```

**Sample data**

```ts
import {
  computeLineAmounts,
  type LineItem,
  type LineItemInput,
} from "@/artifacts/asterdoc/line-items";
import type { RuntimeParty } from "@asterdoc/types";

import type { DocumentData } from "@asterdoc/react";

/**
 * Sample data for the purchase order.
 *
 * One set, thirty-two rows, which is several times the one page of content
 * `Paper` exposes as `PAGE_CONTENT_HEIGHT_PX` (960 pixels: US Letter at 96 dpi
 * less both margins) holds. That is deliberate, for two reasons. The purchase
 * order is the packet's composition, and a packet whose first part runs to
 * several pages is what proves a part's boxes are offset into packet pages
 * rather than left in the part's own. And the block installs this data: a
 * consumer who renders it should see the table continue across more than one
 * break, with its header copied onto every continued page, rather than a
 * document that happens to spill once.
 *
 * The proposal sample carries a short and an overflow set because its suite
 * measures pagination against both. This composition has no such need, so
 * there is exactly one set.
 */

const CURRENCY = "USD";
const TAX_RATE_PERCENT = 8.25;

const buyer = {
  name: "Harbor Freight Collective",
  legalName: "Harbor Freight Collective, Inc.",
  domicile: "US",
  entityType: "Corporation",
  taxId: "58-1029384",
};

const supplier = {
  name: "Northgate Systems",
  legalName: "Northgate Systems, LLC",
  domicile: "US",
  entityType: "Limited liability company",
  taxId: "47-2938471",
};

const LINE_ITEMS: LineItemInput[] = [
  { description: "27-inch 4K monitor", quantity: 24, unit: "each", unitPrice: { amount: 385, currency: CURRENCY } },
  { description: "Docking station, USB-C triple display", quantity: 24, unit: "each", unitPrice: { amount: 210, currency: CURRENCY } },
  { description: "Mechanical keyboard and mouse set", quantity: 24, unit: "set", unitPrice: { amount: 95, currency: CURRENCY } },
  { description: "Adjustable monitor arm, dual", quantity: 12, unit: "each", unitPrice: { amount: 165, currency: CURRENCY } },
  { description: "Sit-stand desk frame, 1600mm", quantity: 24, unit: "each", unitPrice: { amount: 620, currency: CURRENCY } },
  { description: "Acoustic desk divider", quantity: 24, unit: "each", unitPrice: { amount: 88, currency: CURRENCY } },
  { description: "48-port managed network switch", quantity: 2, unit: "each", unitPrice: { amount: 1450, currency: CURRENCY } },
  { description: "Wireless access point, Wi-Fi 7", quantity: 6, unit: "each", unitPrice: { amount: 540, currency: CURRENCY } },
  { description: "Cat 6A patch panel, 48 port", quantity: 2, unit: "each", unitPrice: { amount: 310, currency: CURRENCY } },
  { description: "Rack-mount UPS, 3000VA", quantity: 2, unit: "each", unitPrice: { amount: 890, currency: CURRENCY } },
  { description: "Server rack, 24U, with fans", quantity: 1, unit: "each", unitPrice: { amount: 1180, currency: CURRENCY } },
  { description: "Conference room display, 75-inch", quantity: 3, unit: "each", unitPrice: { amount: 1890, currency: CURRENCY } },
  { description: "Video bar with speaker track", quantity: 3, unit: "each", unitPrice: { amount: 1420, currency: CURRENCY } },
  { description: "Badge reader, door controller", quantity: 4, unit: "each", unitPrice: { amount: 395, currency: CURRENCY } },
  { description: "Structured cabling, per drop", quantity: 96, unit: "drop", unitPrice: { amount: 74, currency: CURRENCY } },
  { description: "On-site installation and cable management", quantity: 9, unit: "day", unitPrice: { amount: 750, currency: CURRENCY } },
  { description: "Network configuration and cutover", quantity: 4, unit: "day", unitPrice: { amount: 980, currency: CURRENCY } },
  { description: "Asset tagging and handover documentation", quantity: 2, unit: "day", unitPrice: { amount: 640, currency: CURRENCY } },
  { description: "Task lighting, desk-clamp LED", quantity: 24, unit: "each", unitPrice: { amount: 62, currency: CURRENCY } },
  { description: "Task chair, mesh back, adjustable arms", quantity: 24, unit: "each", unitPrice: { amount: 430, currency: CURRENCY } },
  { description: "Under-desk cable tray, 800mm", quantity: 24, unit: "each", unitPrice: { amount: 34, currency: CURRENCY } },
  { description: "Power module, desk-mounted, 2 socket 2 USB-C", quantity: 24, unit: "each", unitPrice: { amount: 78, currency: CURRENCY } },
  { description: "Locker, personal storage, 4 tier", quantity: 6, unit: "each", unitPrice: { amount: 540, currency: CURRENCY } },
  { description: "Whiteboard, magnetic, 2400mm", quantity: 4, unit: "each", unitPrice: { amount: 295, currency: CURRENCY } },
  { description: "Acoustic ceiling baffle, 1200mm", quantity: 40, unit: "each", unitPrice: { amount: 46, currency: CURRENCY } },
  { description: "Meeting pod, two person, ventilated", quantity: 2, unit: "each", unitPrice: { amount: 6400, currency: CURRENCY } },
  { description: "Fire-rated network cabinet, wall mount", quantity: 2, unit: "each", unitPrice: { amount: 720, currency: CURRENCY } },
  { description: "Environmental sensor, temperature and humidity", quantity: 8, unit: "each", unitPrice: { amount: 118, currency: CURRENCY } },
  { description: "Digital signage player, 4K", quantity: 3, unit: "each", unitPrice: { amount: 340, currency: CURRENCY } },
  { description: "Floor box, four compartment", quantity: 12, unit: "each", unitPrice: { amount: 155, currency: CURRENCY } },
  { description: "Electrical works, per circuit", quantity: 18, unit: "circuit", unitPrice: { amount: 260, currency: CURRENCY } },
  { description: "Site survey and as-built drawings", quantity: 1, unit: "lot", unitPrice: { amount: 2450, currency: CURRENCY } },
];
/**
 * The sample's data, whose parties carry runtime ids.
 *
 * A document only prints a party, so `DocumentData` asks for the wider `Party`.
 * The seal binds a signer to a party by id, so the sample states that its own
 * parties have one rather than asserting it later.
 */
export interface PurchaseOrderData extends DocumentData {
  parties: Record<string, RuntimeParty | RuntimeParty[]>;
}

const { lineItems } = computeLineAmounts(LINE_ITEMS, CURRENCY);

/** The purchase order sample: office and IT hardware ordered for a new floor. */
export const purchaseOrderData: PurchaseOrderData = {
  fields: {
    orderNumber: "PO-2026-0512",
    issuedOn: "2026-09-04",
    deliverBy: "2026-10-02",
    buyer,
    buyerAddress: {
      line1: "88 Wharf Road",
      locality: "Oakland",
      region: "CA",
      postalCode: "94607",
      country: "US",
    },
    // Not printed. The seal binds the buyer's signer to this person: core's
    // Signer.person is always a Person, and the party is an organization.
    buyerContact: { name: "Marisol Vega", firstName: "Marisol", lastName: "Vega", title: "Ms." },
    supplier,
    supplierAddress: {
      line1: "1400 Rio Grande Street",
      line2: "Suite 220",
      locality: "Austin",
      region: "TX",
      postalCode: "78701",
      country: "US",
    },
    supplierContact: { name: "Dana Whitfield", firstName: "Dana", lastName: "Whitfield", title: "Ms." },
    shipTo: {
      line1: "88 Wharf Road",
      line2: "Loading Dock B",
      locality: "Oakland",
      region: "CA",
      postalCode: "94607",
      country: "US",
    },
    currency: CURRENCY,
    lineItems,
    taxRatePercent: TAX_RATE_PERCENT,
    terms: "Payment is due 30 days from delivery. Goods remain the property of the supplier until paid in full.",
  },
  parties: {
    buyer: { id: "buyer-0", ...buyer },
    supplier: { id: "supplier-0", ...supplier },
  },
};

/**
 * What a session has answered, in the artifact's own shape.
 *
 * `@asterdoc/sessions` publishes exactly this from `sessionPayload`, and this
 * module states the shape rather than importing it so that a document package
 * does not depend on a session engine to render a document.
 */
export interface PurchaseOrderPayload {
  fields: Record<string, unknown>;
  parties: Record<string, unknown>;
}

/**
 * The document data for a purchase order that is still being answered.
 *
 * Each row's `amount` is derived rather than answered, so it does not belong
 * in a session's log: it is the row multiplied out. A session collects the rows
 * and the currency; this computes the amounts, exactly as the sample above
 * does, and it does so on every call so the artifact's totals follow the rows
 * as they land.
 *
 * Everything else passes through untouched. A field nobody has answered yet is
 * simply absent, which is what the composition renders as blank.
 */
export function purchaseOrderDocumentData(payload: PurchaseOrderPayload): PurchaseOrderData {
  const fields: Record<string, unknown> = { ...payload.fields };
  const rows = fields.lineItems;
  const currency = fields.currency;

  // The amounts are money and money has a currency. Before the currency lands
  // the rows print their own values and the totals print blank, which is the
  // honest state of a document that cannot add up yet.
  if (Array.isArray(rows) && typeof currency === "string" && currency.length > 0) {
    fields.lineItems = computeLineAmounts(rows as LineItemInput[], currency).lineItems;
  }

  return {
    fields,
    // The parties a session answered are validated by the artifact's own party
    // schema before the log records them, so what comes back carries the id a
    // seal binds a signer to.
    parties: payload.parties as PurchaseOrderData["parties"],
  };
}

/**
 * The same sample as a filler would supply it, with nothing derived in it.
 *
 * A session records what it was told, so a script that answers a computed value
 * puts one in an event log that is supposed to hold only answers. This is
 * `purchaseOrderData` with each row's derived `amount` taken back out. Feed it
 * to a fill and `purchaseOrderDocumentData` puts the amounts back.
 */
export const purchaseOrderAnswers: PurchaseOrderPayload = {
  fields: Object.fromEntries(
    Object.entries(purchaseOrderData.fields).map(([path, value]) =>
        path === "lineItems" && Array.isArray(value)
          ? [
              path,
              (value as LineItem[]).map(({ amount: _amount, ...row }) => row satisfies LineItemInput),
            ]
          : [path, value]
      )
  ),
  parties: { ...purchaseOrderData.parties },
};
```


## Composition

The purchase order composes [`Document`](/components/document),
[`Section`](/components/section), [`Text`](/components/text),
[`Field`](/components/field), [`Table`](/components/table),
[`Totals`](/components/totals), and [`Signature`](/components/signature).
The closing "Acceptance" section places both parties' signature blocks side
by side, `buyer` and `supplier`, the same arrangement
[`Signature`](/components/signature)'s own docs page shows.

The line-item [`Table`](/components/table) sits in the same section as the
[`Totals`](/components/totals) that follows it, and neither carries a copy
of the subtotal, tax, or total — `@asterdoc/core` evaluates each from the
artifact's own `defs`. The title in the masthead is a `Text` heading, and no
line of the order sets its own text size. The buyer and the supplier print
through their `Field`s rather than `Party`: a session fills this order, and
`Party` refuses a role nobody has answered yet where `Field` prints the blank
placeholder. A [`Page Number`](/components/page-number) in the footer,
exported as `purchaseOrderFurniture`, numbers the order's pages in the
preview, in the PDF, and inside the vendor packet.

Like the invoice, the composition renders bare, with no self-wrapping
[`Pages`](/components/pages): it is used both on its own and as one part of
the [vendor packet](https://docs.asterdoc.com/r/vendor-packet.json), whose own
layer supplies the bundle around it, so this composition lets the caller
decide. This page's Preview wraps it in `Pages` exactly the way a standalone
consumer's own page would. Install `pages` explicitly for that wrapper; the
block still brings `@asterdoc/core`, which evaluates its defs.

## Variants

A block's Variants section shows an existing alternate sample-data scenario,
never a new prop configuration. Purchase order has exactly one sample-data
export today, `purchaseOrderData` — the same 32-row order already rendered
above in Preview. There is no second scenario to show, so rather than invent
one, this variant documents that and renders the same real data again.

### Standard order

The block's one real sample scenario, shown again for its own subheading.


```tsx
import { Pages } from "@/components/asterdoc/pages";
import { purchaseOrderData } from "@/artifacts/asterdoc/purchase-order.data";
import {
  PurchaseOrderDocument,
  purchaseOrderFurniture,
} from "@/components/asterdoc/purchase-order";

export function PurchaseOrderBlockPreview() {
  return (
    <Pages furniture={purchaseOrderFurniture}>
      <PurchaseOrderDocument data={purchaseOrderData} />
    </Pages>
  );
}
```
