Purchase OrderLast updated on
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
Installation
npx shadcn@4 add @paradoc/purchase-orderOr, with the Paradoc CLI, which writes the namespace into components.json for you:
npx paradoc-cli add purchase-orderInstalls 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.
Invoice
An invoice nothing signs: the artifact with its React layer, the composition branded from its own tokens, and two samples — one page, and one whose table runs past two breaks.
Vendor Packet
A packet of three documents — the purchase order composed live, a filled W-9 painted from its PDF layer, and the vendor's certificate of insurance as an annex, whose bytes it ships.