Purchase Order

Last updated on

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

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

Rendering live preview requires JavaScript.

Installation

npx shadcn@4 add @paradoc/purchase-order

Or, with the Paradoc CLI, which writes the namespace into components.json for you:

npx paradoc-cli add purchase-order

Installs to artifacts/paradoc/purchase-order.artifact.ts, artifacts/paradoc/purchase-order.data.ts, and components/paradoc/purchase-order.tsx and brings along document, field, page-number, priced-line-items, section, signature, table, text, totals.

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

components/paradoc/purchase-order.tsx

/** @jsxRuntime classic */import React from "react";import type { Form } from "@paradoc/types";import { Document } from "@/components/paradoc/document";import {  markDocumentRoot,  type DocumentData,  type DocumentTokensInput,  type FormatOptions,  type PageFurniture,} from "@paradoc/react";import { Field } from "@/components/paradoc/field";import { PageNumber } from "@/components/paradoc/page-number";import { Section } from "@/components/paradoc/section";import { Signature } from "@/components/paradoc/signature";import { Table } from "@/components/paradoc/table";import { Text } from "@/components/paradoc/text";import { Totals } from "@/components/paradoc/totals";import { purchaseOrderForm } from "../../artifacts/paradoc/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

artifacts/paradoc/purchase-order.data.ts

import {  computeLineAmounts,  type LineItem,  type LineItemInput,} from "@/artifacts/paradoc/line-items";import type { RuntimeParty } from "@paradoc/types";import type { DocumentData } from "@paradoc/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. * * `@paradoc/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, Section, Text, Field, Table, Totals, and Signature. The closing "Acceptance" section places both parties' signature blocks side by side, buyer and supplier, the same arrangement Signature's own docs page shows.

The line-item Table sits in the same section as the Totals that follows it, and neither carries a copy of the subtotal, tax, or total — @paradoc/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 Fields 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 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: it is used both on its own and as one part of the vendor packet, 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 @paradoc/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.

Rendering live preview requires JavaScript.

On this page