Document Detail Page
The screen for a single record. Two columns: the record itself on the left, and on the right the actions available on it plus the context that sits around it rather than inside it — where it is mirrored in another system, who has changed it.
It is not only for documents. Orders, receipts and invoices use the fullest version of it; master-data records — a supplier, an item, a site — use the same shape with fewer sections, typically a summary and a few actions and nothing else. What follows covers the full version; skip what a given record doesn't have.
When to Use
- A screen showing exactly one record
- The record has a lifecycle someone moves it through, or other records created from it
- Someone reading the record needs to understand its current state, and may want to act on it
What the page answers
The records this applies to vary widely in what they mean and what they carry. For example, a purchase order might carry prices and an approval chain where a goods receipt carries neither. What stays the same is the sequence someone works through when they open the page, and the layout follows that sequence:
- What is this record, and is it still in progress? → the alerts, then the summary
- What is in it? → the line items
- What is it connected to, and what has it caused? → its sources, the records created from it, its accounting entries
- What can I do about it? → the actions in the right-hand column
A record skips whichever of these it doesn't have. It never reorders the ones it does.
High-level layout
An example, with more cards filled in than most records will have:
+---------------------------------------------------------+
| Layout.Header Purchase order PO-24118 breadcrumb |
+----------------------------------+----------------------+
| Layout.Column (main) | Layout.Column right |
| Alert terminal states only | ActionPanel |
| | Duplicate |
| DescriptionCard Summary | Amend / Edit |
| identity · statuses | Create receipt |
| supplier → link | Close |
| | |
| Card Source documents (up) | Card External |
| Card Line items | QBO · Bill 1042 |
| Card Goods receipts (down) | |
| Card Journal | ActivityCard |
| Card Reference documents | History |
| | |
+----------------------------------+----------------------+Below 1024px wide, the right-hand column drops beneath the main one. That is why nothing needed to understand the record may live only on the right: that column carries actions and surrounding context, not content.
Main column
Cards in this order. Skip what doesn't apply; don't reorder what does.
| Card | Appears when | Job |
|---|---|---|
| Status alerts | A terminal state, or one whose consequence is invisible | Explain the missing actions before they're hunted |
| Summary | Always | Identity and current state |
| Upstream / exceptions | The line items only make sense in context; a block is active | Where the contents came from; what's holding it up |
| Line items | The record has contents | The record's own content |
| Related records | The relationship is possible | What this record has caused |
| Reference documents | Files hang off the record | Attachments, with a viewer link |
The summary is the only card that always appears. Line items are on most documents but not on all records — a supplier or a site has no contents to list, and simply omits the card rather than showing an empty one.
1. Status alerts — above everything
When a record reaches a state that removes its actions, say so. Otherwise the action list silently shortens and the reader goes looking for a button that is no longer there.
Write one alert per state, not a single generic "this record is closed":
- Settled / completed →
success. The work finished and the figures are frozen. - Cancelled / voided →
neutral. Nothing happened; a new record is needed. - Still in progress, but the next step can't be undone →
info. Also for a control someone would expect that genuinely doesn't exist. - Rejected back to draft →
warning. Without it, a bounced record is indistinguishable from a fresh one.
These are persistent, not dismissible. The alert is derived from the record's state, so it belongs on screen for exactly as long as the record is in that state — don't pass dismissible. Transient feedback after an action is useToast, never an Alert.
Alert is compound: Alert.Root variant wrapping Alert.Title and Alert.Description.
2. Summary — always first
One DescriptionCard, columns={3}, titled for the record ("Purchase order information"). Self-containing — never wrap it in a Card.Root. Field order:
- Identity — the document number or code first,
meta: { copyable: true }, because it's the thing people paste into chat - Statuses — the record's own, then any derived ones (see below)
- The other party and place — the supplier, customer or site the record is with, as a
type: "link". PointhrefKeyat an href computed before render, so a record whose counterpart can't be resolved shows plain text rather than a dead link - A
{ type: "divider" }, then dates, commercial terms and external references
Only fields the record can actually answer. A goods receipt has no supplier and no path to one, so it gets a count of source documents instead, and the supplier lives on the order the receipt points at. Deriving a field from data already loaded — a line count, an order total, a resolved source set — is fine and common. Inventing one the schema can't answer is how a page ships broken.
Its own status, and the ones it derives
A record usually shows more than one status. It has its own status — where it is in its own lifecycle — and it often also shows derived statuses that summarise the state of related records or external events: whether the ordered goods have arrived, whether the invoice has been paid, whether a sync to an external system succeeded.
Show each one as its own field with its own badge. Collapsing them into a single value loses exactly what the page exists to convey. The record's own status is a filled semantic variant; the derived ones are outline-*, so the reader can see at a glance which status the record itself owns.
Never put a control on a derived status. Not a select, not a "mark as received" action, not an override. Those values are written by whichever module owns receiving, or billing, or the integration — a control here would claim an ownership this screen doesn't have. Render the value, and put the records behind it in a related-records card.
Conditional and empty fields
A field with no value renders an em dash by default (emptyBehavior: "dash"). The alternatives are to hide it (emptyBehavior: "hide") or to leave it out of the array entirely — and both move everything after it.
DescriptionCard lays its fields out as a grid in DOM order, so removing a field shifts every field after it into the vacated slot. A field that sat third in a row moves to second. Two records of the same type can then present the same information in different positions, which is exactly what a summary card should not do.
So:
- Default to the dash. For any field that is part of what this record type always shows, leave it rendered and let it show an em dash. Position stability is worth more than density.
- Remove a field only when its absence is itself meaningful — a close reason on a record that was never closed, a rejection note on one never rejected. There, the reader isn't comparing positions, and an em dash would imply the field applies when it doesn't.
- Put conditional fields at the end of their section. A
{ type: "divider" }starts a new grid, so a field removed after a divider can only shift fields within that same section. Grouping the conditional ones last means removal shifts nothing the reader is comparing.
A second DescriptionCard is right when a group of fields has a different owner or a different edit path from the first — system-derived versus operator-entered.
3. Upstream sources and exceptions
Upstream sources are the documents this record was created from. They go above the line items when the line items only make sense once you know where they came from. A goods receipt is the clearest case: each of its lines is a receipt of a specific purchase-order line, so the quantities mean nothing until the reader can see which orders are being received against. Show each source with its document number, its status badge, and a link out.
Exceptions and blocks are anything actively stopping the record from moving on — a payment hold, a failed validation. They go directly under the summary, given the destructive (red) treatment because they are a problem to clear rather than information to read, and rendered only when at least one is active. Each hold is listed as its own entry, and the control that releases it sits beside its reason. This is the one place an action belongs in the main column rather than in the right-hand column: releasing a hold is done to the hold, one at a time, and the record is unblocked only once every one is released.
4. Line items
Card.Root → Card.Header (title, plus a one-line description where the columns need explaining) → Card.Content className="px-0!" → Table.Root.
Fetch the line items with the record, sorted explicitly so the order is stable across reloads, and render them as a plain Table. For the typical document — a handful to a few dozen lines — that is right: a toolbar and pagination would add controls with nothing to do.
Some document types carry far more, so size the table for the document type's realistic maximum rather than for the record in front of you. Where a line count can run to hundreds, bound the table: give it an internal scroll region so the card doesn't grow without limit, and page the lines where scrolling alone would be unwieldy. A query cap such as lines(first: 1000) bounds the query, not the table — a record that reaches it shows an incomplete set with nothing on screen saying so, and it is never a substitute for scrolling or paging.
Team input needed — a shared line-items component. Every app builds this table by hand today, and the read and edit versions drift apart. The erp-kit templates carry the closest thing to a starting point: a form-bound
LineItemsTablewith optional client-side paging and a footer that mirrorsDataTable.Pagination. Nothing in AppShell yet covers reading and editing line items, with paging, in one component.
- [ ] Decide whether AppShell should own a line-items component, and what it covers
Columns, left to right:
- Identity — the item name, with the SKU beneath it in muted mono
text-xs. Two facts, one column. - The record's own numbers — quantity, unit of measure, unit price. Every document has these.
- Numbers from related records, where they exist and the reader needs them (see below).
- Subtotal, derived, last.
Numeric columns take align="right" on both the head and the cell, and tabular-nums so digits line up. An empty table is an explicit muted paragraph inside the card, never a bare header row.
Numbers from related records
A line can also carry figures that come from other documents. Which ones — if any — depends on where the record sits in its chain:
- A record that others fulfil (an order) can show what has happened against each line: received, billed, shipped.
- A record that fulfils another (a receipt, a shipment) can show what it is fulfilling against: the quantity originally ordered, what other siblings have already taken, what remains open.
- Many records show neither and carry only their own numbers.
These are not a fixed part of the table — implementations differ on the same document type — so include them only where those related records exist and the reader is reconciling against them. Where a figure is shown against a target, put the difference beside the actual figure as muted text rather than adding a separate variance column.
A total row is a claim, not a decoration. Add
Table.Footeronly where the column genuinely sums. An order priced in one currency totals cleanly. A goods receipt often cannot total its quantities at all: one shipment might take 6 cases of one item and 120 kilograms of another, and there is no unit those add up in. Likewise omit a currency symbol on a record whose schema has no currency to qualify it. Both mistakes look like polish and read as fabrication.
5. Related records — what this one caused
One card per type of related record: receipts against an order, invoices against a receipt, payments applied, the instalments still due, the accounting entries the record produced. Each is a small table of document number → status → date → the figure that matters, with the number linking to that record's own page.
- Each entry's status badge is that record's own status, so it takes the filled semantic variant — the same treatment it gets on its own page.
- Order the cards the way the work runs — goods movements before money movements
- The empty state names the relationship ("No goods receipts linked to this order"), so the reader learns the relationship exists and is simply unused
- Hide a card entirely only while the relationship is impossible — a draft can have no receipts yet. Once it is possible, show it empty rather than hiding it
- Group by parent where the hierarchy is real — orders, then the receipts under each
Team input needed — how much these tables should do. They are written here as pointers: identify the related record, link to it, stop. The alternative is letting them carry enough columns to answer a question without leaving the page, at the cost of a second place that reports on the same data. Where a related collection genuinely needs filtering and paging, the fallback is
pattern/list/dense-scanon its own route, linked from here.
- [ ] Decide how far a related-records table goes before it becomes its own screen
When a relationship is a card, and when it is a field on the summary
Not every related record deserves a card. The question comes up for every relationship the record has, and the answer follows from how many records are on the other end:
A relationship to one other record is a link field on the summary. A relationship to many other records needs a card.
Typically, if the record has a parent it has only one, so the parent appears as a link on the summary description card. If it has child records there are many of them and their statuses matter, so they go in a table inside their own card — which is more than a field could carry.
The exception worth knowing is many records upstream: a receipt consolidating three orders, an invoice raised against several receipts. That is many records on the other end, so it takes a card of its own, placed above the line items. Count, not direction, is what decides.
The other case for a card is a relationship the API cannot follow. A polymorphic link — a sourceType / sourceId pair with no union type and no relation field on either side — cannot be traversed in a GraphQL query in either direction, so it has to be resolved by the page. A field cannot express it.
If you find yourself building a card to display a single supplier, that relationship belongs on the summary instead.
Accounting entries
A record that gets posted to the ledger produces accounting entries, and those belong on the record. Before posting they are a preview computed from what the page already holds, and must be labelled as one. After posting they are the real entries, fetched by document number — and posting often books more than one, so fetch the whole set rather than the obvious one.
Opening a related record
A related record's document number links through to that record's own page. That is the convention across every implementation reviewed, and the default here. Add an open-in-a-new-tab affordance on each entry — Omakase pins a new-tab control as a column and offers the same through a right-click menu — so someone comparing records can keep this one open.
Team input needed — the cross-checking case. Someone matching an invoice against its receipts wants to glance at each receipt without losing their place. Link-through handles that only via a new tab. The alternatives seen are a modal over the current page (the erp-kit journal entry — the sole exception among 25 collection tables there, and that record has a detail route as well) and a Sheet, which Denim Tears uses for revision history rather than for related records.
- [ ] Decide whether a modal or a Sheet is ever the right answer here, and what determines it — the depth of the record being opened, or whether the reader is comparing or leaving
6. Reference documents
Files that hang off the record — a supplier's signed contract, a scanned delivery note, a photo of damaged goods. One card titled Reference documents, near the end of the main column, listing each file with a viewer link, and an upload control gated on the record's state.
Secondary (right-hand) column
Ordered from what the reader acts on to what they only consult, because below 1024px this column becomes the page's footer.
1. ActionPanel — first, titled "Actions"
Everything here does something to this record: changes it, moves it through its lifecycle, or creates the record that comes next in the workflow.
A serviceable starting set, in order:
| Action | When |
|---|---|
| Download PDF | records that get sent to another party |
| Duplicate | almost always |
| Edit | while the record is still a draft |
| Amend | once it's committed and a change has consequences |
| Create «child record» | when the lifecycle allows the next document |
| Close / Cancel | the terminal transitions |
Icons come from lucide-react, one per verb. Use the same glyph for the same verb on every record, so people learn the panel once:
| Verb | Icon |
|---|---|
| Edit (a draft) | Pencil |
| Amend (a committed record) | FileEdit |
| Duplicate | Copy |
| Download PDF | Download |
| Delete (a draft) | Trash2 |
| Submit / Post / send onward | Send |
| Approve / Confirm | Check |
| Reject | Ban |
| Cancel / Close | XCircle |
| Redraft / reopen | RotateCcw |
| Release (a hold) | Unlock |
| Receive | PackageCheck |
| Create «goods receipt» | PackagePlus |
| Deactivate / Reactivate | UserX / UserCheck for people, ShieldOff / ShieldCheck for roles |
| Revision history | History |
Pass the bare element — icon: <Pencil /> — and let ActionPanel size it.
Never a back button — not here, and nowhere else in the top-right. Two separate reasons, and each is sufficient on its own. Navigation is not an action on the record, so it does not belong in a panel of them; and the top-right of a screen is not where anyone looks to go back. Back is the breadcrumb's job, top left, where the reader already came from. A panel opening with "Back to orders" also trains people to read it as a menu rather than as the set of things they can do to what they are looking at.
Mechanics:
- Include an action only when the record's status allows it — in code, by spreading each status's actions into the array conditionally — so the panel only ever offers what's legal now. A hidden entry beats a disabled one.
- Bind
loadingon any entry that fires a mutation, from that mutation's own in-flight state;variant="destructive"for the destructive ones. - Don't pre-compute a disabled state the server owns. Some commands refuse on state the page can't see — cancelling once a line is billed, closing before every line settles. Leave the action enabled and let the failure surface the server's own message. A guessed disabled state drifts from the command and leaves a dead button with no explanation.
- Report both outcomes. Success and failure both toast; no silent writes.
- An action needing input or confirmation opens a dialog from the same place — see
pattern/interaction/confirm. The confirm button mirrors the command's own conditions and is no stricter: making a reason unconditionally required when the command only wants it in one case blocks the path the server would have accepted. Say in the dialog what the action does when the button doesn't make it obvious — that rejecting returns the record to draft, or that posting can't be undone. - Export a predicate (
hasOrderActions(status)) alongside the panel, so the screen can decide whether it renders at all. ActionItemisn't exported; annotate an actions array asActionPanelProps["actions"].
Terminal states. The lifecycle actions all drop out, and it's the alert at the top of the record — not the panel's own thinness — that explains their absence. The always-available utilities legitimately outlive the workflow: a settled order can still be duplicated or downloaded, so the panel often survives carrying only those. Render it when something is genuinely in it, omit it entirely when nothing is, and never pad it with navigation to stop it looking bare.
2. External system — under the actions
When the record is mirrored outside the platform — Shopify, QuickBooks, a WMS — one card naming the system, linking to the record over there, and saying when it last synced. Omit the card entirely when there's no such link; never render it empty.
A dedicated component for this is tracked as tailor-inc/platform-planning#775 (Integration Card), which covers the integration's identity and icon, its mapped fields, external links and status indicators. Until it lands, compose the card by hand as above.
3. History — last
Revisions, or an audit trail of who changed what, as an ActivityCard. It's context, not content: someone who never opens it should still understand the record without it.
One treatment, whatever the length. A trail's length varies from record to record of the same type, so it can't decide the presentation — two records of one type showing history in two different places is worse than either choice on its own. ActivityCard caps itself: maxVisible (6 by default) bounds what it renders and the rest collapses behind an overflow label. Set the cap and leave it in the column.
Optional cards
Nothing additional is required; add one only when the record calls for it.
- Metric strip — headline figures. Either lead the main column with
MetricCards in aGrid(columns={{ initial: 1, md: 2, xl: 4 }}, never one per row), or put a single number in the right-hand column above the actions. Not both. DocumentProgressCard— a lifecycle or fulfilment breakdown. Derivepercentandsegmentsin the consumer.- A reconciliation card — for records that other records fulfil, an ordered / received / billed grid. Hide it while there's nothing yet to reconcile: an empty reconciliation table is worse than none. One plain-English line under the title ("3 units still to receive") beats making the reader subtract.
Editing
Prefer editing in place. Where a field is editable in the record's current state, give it an affordance — a pencil beside the value — and swap it for an input where it already sits. The value doesn't move between reading and editing, so someone learns one screen position per field instead of two, and the page doesn't rearrange itself around the act of editing.
That is the default. Escalate only when the edit genuinely can't be done a field at a time:
| Scope | Mechanism |
|---|---|
| One field, or a handful independent of each other | In place, on the summary or the line |
| A coherent group that must validate or save together | A dialog opened from the actions |
| The whole record and all its line items | A sub-route rendering this screen with the form over it |
What is editable is always the product of two things — the record's lifecycle state and the reader's permission — resolved into named booleans once, near the top of the component, and not re-derived inline. Name them for what they permit (lineItemsEditable) or reveal (showReceiptsCard), so the JSX reads as intent rather than as a chain of status comparisons.
In practice a draft is broadly editable in place, and a committed record still has a few fields that are: a delivery date, a note, a currency, a reference someone needs to correct without amending the whole record.
Team input needed — in-place editing versus an edit route. The guidance above prefers in-place, and separately describes
/editand/amendsub-routes. The two pull in different directions: if fields toggle between reading and editing where they sit, it isn't obvious what a whole-record edit form is still for, or how someone chooses between them. Both exist in the field today.
- [ ] Decide where the boundary sits between editing in place and opening an edit route, and whether both should be offered on the same record
A sub-route is an edit form with a URL of its own that opens as a dialog over this screen: /edit and /amend render the same record behind the form, so the reader keeps their context and the URL stays shareable. Two things it has to get right:
- The status gate has to hold on a cold load. A URL can be typed, bookmarked, or reloaded after someone else moved the record on. When the status no longer permits the form, redirect to the read-only screen and replace the history entry, so Back doesn't bounce into the redirect.
- Every dismissal path funnels through one handler — close, cancel, Escape, backdrop, and a successful save all return to the detail URL.
Even fully editable, the screen still reads as a record with editable fields, not as a form: the cards keep their order, and nothing collects a page-level Save at the bottom.
When a tab strip is warranted
One scrolling column is the shape. Everything about the record — its summary, its contents, its related records — belongs on that one column, and a tab strip is not a way to tidy it up. A strip is never an alternative view of data that belongs on the cards.
A second tab is warranted when the record caused a separate accounting or inventory record, and that record is the subject of the tab. The cases that come up in practice:
| Second tab | Holds |
|---|---|
| Stock movement | The inventory transactions posting this record created |
| Journal / GL entry | The general-ledger entries posting this record booked |
Those qualify because the tab's subject is a different record type with its own identity and its own lifecycle — not another view of this one. Another case may meet the same test; if it does, it belongs here too. What settles it is whether you can name the other record type the tab is about. If you can't, there is no second tab.
Explicitly not grounds for a strip:
- The column is long, or scrolling feels like a lot
- The related-record cards are numerous
- A group of fields feels like it deserves its own space
- Someone would like a "details" tab and an "activity" tab
Two rules come with a strip that does qualify, and both follow from the same point: the page's subject is the primary record, and a second tab shows records it produced. Those are consequences, read-only here — to act on one, open its own page.
- Alerts stay above the strip. An alert describes the primary record's state, and that doesn't change with the tab. Inside the strip it would read as describing whatever the open tab holds.
- The
ActionPanelstays out of the tabs, in the right-hand column. Its actions apply to the primary record only. Inside the strip they would appear to act on the tab's records, which this screen never does.
A third rule is practical: if the second tab's content goes away — nothing posted yet, so no journal — the strip goes with it. A one-tab strip is worse than no tabs.
Where a long column genuinely needs wayfinding, the affordance is still a strip of tabs across the top — but they scroll rather than switch. Every card stays on the one page; selecting a tab scrolls that card into view, and the active tab follows the reader's scroll position. It looks like a tab strip and behaves like a table of contents. AppShell ships no primitive for it, so treat it as a considered addition rather than a default.
Links
One treatment everywhere on the page — DescriptionCard's own link fields, the document numbers in related-record tables, the external-system card:
text-primary at rest, underline only on hover — the primary colour is what says "clickable" without a hover, and the resting underline is noise. Add underline-offset-4 so the hover underline clears descenders.
- Internal (another route in the app) → the
Linkexported by app-shell, withto. Never a bare<a href>for an in-app route. - External (another system) →
<a target="_blank" rel="noopener noreferrer">with a lucideExternalLinkatsize-3after the label, so the new tab is signposted. There is no dedicated external-link component; this is the whole recipe. - Identifiers keep their
font-monoon top of the link style. - A
renderfield owns its typography.DescriptionCardapplies none of its value styling to custom output, so a bare string fromrenderreads a size larger than the values around it. Wrap it intext-sm font-medium text-foreground— the reference implementation'sValuehelper.
Patterns for the parts
The page decides the frame; these build what sits in it.
| Need | Pattern |
|---|---|
| A related collection big enough for its own screen | pattern/list/dense-scan |
| Editing a coherent group of header fields | pattern/form/modal |
| A full edit of the record and its line items | pattern/form/sectioned on a sub-route |
| Confirming a destructive transition | pattern/interaction/confirm |
| Reporting an action's outcome | pattern/interaction/toast |
Reference implementation
A confirmed purchase order: terminal-state alerts, a summary with its own status plus two derived ones and a link to its source, a many-upstream card above the line items, line items showing received quantities from the order's receipts and a total that legitimately sums, a related-records card, and a right-hand column of actions, external-system link and history.
/* page: detail */
import {
ActionPanel,
ActivityCard,
Alert,
Badge,
Button,
Card,
DescriptionCard,
Layout,
Link,
Table,
Tabs,
} from "@tailor-platform/app-shell";
import type { ActionPanelProps } from "@tailor-platform/app-shell";
import {
Copy,
ExternalLink,
FileEdit,
FileText,
PackagePlus,
Pencil,
Unlock,
XCircle,
} from "lucide-react";
import type React from "react";
import type { ExternalSource, PurchaseOrder } from "./mock";
type Props = {
order: PurchaseOrder;
/** Omitted when the record has no third-party counterpart. */
externalSource?: ExternalSource;
onEdit: () => void;
onAmend: () => void;
onDuplicate: () => void;
onClose: () => void;
onCreateGoodsReceipt: () => void;
onReleaseHold: (holdId: string) => void;
closing: boolean;
};
/**
* Bare `Date` scalars arrive as "YYYY-MM-DD". `type: "date"` would hand them to
* `new Date(value)`, which reads them as UTC midnight and renders the previous
* day west of Greenwich — so format them as plain text instead. Real `DateTime`
* fields keep `type: "date"`.
*/
const formatDay = (value: string) =>
new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
/**
* A `render` field owns its own presentation — DescriptionCard applies none of
* the built-in value typography to it — so match that style here or the two
* dates read a size larger than every other value on the card.
*/
const Value = ({ children }: { children: React.ReactNode }) => (
<span className="text-sm font-medium text-foreground">{children}</span>
);
/** House link: primary colour at rest, underline only on hover. */
const DOC_LINK = "font-mono text-xs text-primary underline-offset-4 hover:underline";
export default function PurchaseOrderDetailPage({
order,
externalSource,
onEdit,
onAmend,
onDuplicate,
onClose,
onCreateGoodsReceipt,
onReleaseHold,
closing,
}: Props) {
const isDraft = order.orderStatus === "DRAFT";
const isConfirmed = order.orderStatus === "CONFIRMED";
const isSettled = order.orderStatus === "SETTLED";
const isCancelled = order.orderStatus === "CANCELLED";
const hasJournal = order.journalLines.length > 0;
// Assembled by status-gated spreads, so the rail only ever offers what is
// legal now. Nothing here navigates — the breadcrumb owns that.
const actions: ActionPanelProps["actions"] = [
{ key: "duplicate", label: "Duplicate", icon: <Copy />, onClick: onDuplicate },
...(isDraft ? [{ key: "edit", label: "Edit", icon: <Pencil />, onClick: onEdit }] : []),
...(isConfirmed
? [
{ key: "amend", label: "Amend", icon: <FileEdit />, onClick: onAmend },
{
key: "create-goods-receipt",
label: "Create goods receipt",
icon: <PackagePlus />,
onClick: onCreateGoodsReceipt,
},
{
key: "close",
label: "Close order",
icon: <XCircle />,
onClick: onClose,
// Bound to the mutation's own in-flight state. The action stays
// enabled even though the server may refuse — a guessed disabled
// state drifts from the command and explains nothing.
loading: closing,
variant: "destructive" as const,
},
]
: []),
];
const recordContent = (
<>
{/* An active block, directly under the summary and styled
destructive: it's a problem to clear, not information to read.
Each hold is released on its own, beside its own reason. */}
{order.holds.length > 0 && (
<Card.Root className="border-destructive">
<Card.Header title="On hold" />
<Card.Content className="flex flex-col gap-3">
{order.holds.map((hold) => (
<div key={hold.id} className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-foreground">{hold.reason}</p>
<p className="text-xs text-muted-foreground">
Placed by {hold.placedBy} on {formatDay(hold.placedAt)}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => onReleaseHold(hold.id)}
className="shrink-0"
>
<Unlock className="size-4" />
Release
</Button>
</div>
))}
</Card.Content>
</Card.Root>
)}
<DescriptionCard
title="Purchase order information"
data={order}
columns={3}
fields={[
{ key: "docNumber", label: "Order number", meta: { copyable: true } },
{
key: "orderStatus",
label: "Status",
type: "badge",
meta: {
badgeVariantMap: {
DRAFT: "neutral",
CONFIRMED: "info",
SETTLED: "success",
CANCELLED: "error",
},
},
},
// Progress axes, owned by other modules. Rendered, never controlled.
{
key: "receiptStatus",
label: "Receipt",
type: "badge",
meta: {
badgeVariantMap: { PARTIAL: "outline-warning", COMPLETE: "outline-success" },
},
},
{
key: "billingStatus",
label: "Billing",
type: "badge",
meta: {
badgeVariantMap: { UNBILLED: "outline-neutral", BILLED: "outline-success" },
},
},
{
key: "supplier",
label: "Supplier",
type: "link",
meta: { hrefKey: "supplierHref" },
},
{ type: "divider" },
{ key: "total", label: "Total", type: "money", meta: { currencyKey: "currency" } },
// Bare Date scalars, pre-formatted rather than typed as dates.
{
key: "orderDate",
label: "Ordered",
render: (o) => <Value>{formatDay(o.orderDate)}</Value>,
},
{
key: "expectedDate",
label: "Expected",
render: (o) => <Value>{formatDay(o.expectedDate)}</Value>,
},
// A real DateTime, and nullable, so it keeps type: "date".
{
key: "confirmedAt",
label: "Confirmed",
type: "date",
meta: { dateFormat: "medium" },
emptyBehavior: "hide",
},
]}
/>
{/* Upstream sits above the lines, because the lines came from it and
can't be read without it. Several sources, so a card — a single one
would have been a link field on the summary instead. */}
<Card.Root>
<Card.Header title="Source requisitions" />
<Card.Content className="px-0!">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Requisition</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Raised</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{order.sourceDocuments.map((source) => (
<Table.Row key={source.id}>
<Table.Cell>
<Link to={source.href} className={DOC_LINK}>
{source.docNumber}
</Link>
</Table.Cell>
<Table.Cell>
<Badge variant="success">{source.status}</Badge>
</Table.Cell>
<Table.Cell>{formatDay(source.raisedAt)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
{/* The document's own content. Identity, then its own numbers, then the
received quantity that comes from this order's receipts, then the
derived subtotal. A plain Table: this order has a handful of lines,
fetched with the record — see the entry for when a line set is large
enough to page. No container padding; the cells inset themselves. */}
<Card.Root>
<Card.Header title="Line items" />
<Card.Content className="px-0!">
{order.lineItems.length === 0 ? (
<p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
) : (
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Item</Table.Head>
<Table.Head align="right">Qty</Table.Head>
<Table.Head>Unit</Table.Head>
<Table.Head align="right">Unit price</Table.Head>
<Table.Head align="right">Received</Table.Head>
<Table.Head align="right">Subtotal</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{order.lineItems.map((line) => (
<Table.Row key={line.id}>
{/* Two facts, one column. */}
<Table.Cell>
<span className="block">{line.itemName}</span>
<span className="block font-mono text-xs text-muted-foreground">
{line.sku}
</span>
</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
{line.qty}
</Table.Cell>
<Table.Cell>{line.unit}</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
${line.unitPrice.toLocaleString()}
</Table.Cell>
{/* Actual, with a muted delta beside it when it differs. */}
<Table.Cell align="right" className="tabular-nums">
{line.received}
{line.received !== line.qty && (
<span className="ml-1 text-xs text-muted-foreground">
({line.qty - line.received})
</span>
)}
</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
${line.subtotal.toLocaleString()}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
{/* A footer only because these subtotals genuinely sum — one
currency, one additive column. */}
<Table.Footer>
<Table.Row>
<Table.Cell>Total</Table.Cell>
<Table.Cell colSpan={4} />
<Table.Cell align="right" className="tabular-nums">
${order.total.toLocaleString()}
</Table.Cell>
</Table.Row>
</Table.Footer>
</Table.Root>
)}
</Card.Content>
</Card.Root>
{/* Downstream: what this order has caused. Shown empty once the
relationship is possible, so the reader learns it exists. */}
<Card.Root>
<Card.Header title="Goods receipts" />
<Card.Content className="px-0!">
{order.goodsReceipts.length === 0 ? (
<p className="px-6 text-sm text-muted-foreground">
No goods receipts linked to this order.
</p>
) : (
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Receipt</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Received</Table.Head>
<Table.Head align="right">Qty</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{order.goodsReceipts.map((receipt) => (
<Table.Row key={receipt.id}>
<Table.Cell>
<Link to={receipt.href} className={DOC_LINK}>
{receipt.docNumber}
</Link>
</Table.Cell>
<Table.Cell>
<Badge variant="success">{receipt.status}</Badge>
</Table.Cell>
<Table.Cell>{formatDay(receipt.receivedAt)}</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
{receipt.qty}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
</Card.Content>
</Card.Root>
{/* Files attached to the record, near the end of the column. */}
<Card.Root>
<Card.Header title="Reference documents" />
<Card.Content className="flex flex-col gap-2">
{order.referenceDocuments.map((file) => (
<div key={file.id} className="flex items-center justify-between gap-4">
<a
href={file.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline"
>
<FileText className="size-4 shrink-0" />
{file.name}
<ExternalLink className="size-3 shrink-0" />
</a>
<span className="shrink-0 text-xs text-muted-foreground">
{file.sizeLabel} · {formatDay(file.uploadedAt)}
</span>
</div>
))}
</Card.Content>
</Card.Root>
</>
);
return (
<Layout>
<Layout.Header title={`Purchase order ${order.docNumber}`} />
<Layout.Column>
{/* Terminal states explain themselves, above everything else, so nobody
hunts for actions that are gone. Each state is its own message. */}
{isSettled && (
<Alert.Root variant="success">
<Alert.Title>Settled</Alert.Title>
<Alert.Description>
Every line has been received and billed. The figures below are final.
</Alert.Description>
</Alert.Root>
)}
{/* Live, but the next transition is irreversible — worth saying before
someone reaches for Close. */}
{isConfirmed && (
<Alert.Root variant="info">
<Alert.Title>Closing this order cannot be undone</Alert.Title>
<Alert.Description>
Any quantity still outstanding is written off when the order closes.
</Alert.Description>
</Alert.Root>
)}
{isCancelled && (
<Alert.Root variant="neutral">
<Alert.Title>Cancelled</Alert.Title>
<Alert.Description>
This order was cancelled before it was acted on. Raise a new one to reorder.
</Alert.Description>
</Alert.Root>
)}
{/* The strip sits below the alerts and inside the main column, so it
governs only this column — the actions on the right stay outside it,
and can't read as applying to the tab. Posting books the journal, so
an unposted order has one tab's worth of content and no strip. */}
{hasJournal ? (
<Tabs.Root defaultValue="record" variant="line" className="flex flex-col gap-4">
{/* self-start: Tabs.List centres its tabs inside a full-width box by
default, which reads as centred against left-aligned cards. */}
<Tabs.List aria-label="Purchase order sections" className="self-start">
<Tabs.Tab value="record">Purchase order</Tabs.Tab>
<Tabs.Tab value="journal">Journal entry</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="record" className="flex flex-col gap-4">
{recordContent}
</Tabs.Panel>
{/* A different record type, with its own identity — which is what
qualifies it for a tab rather than another card. Read-only here:
acting on it means opening the journal entry's own page. */}
<Tabs.Panel value="journal">
<Card.Root>
<Card.Header
title="Journal entry"
description="Booked when this order was posted."
/>
<Card.Content className="px-0!">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Account</Table.Head>
<Table.Head align="right">Debit</Table.Head>
<Table.Head align="right">Credit</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{order.journalLines.map((line) => (
<Table.Row key={line.id}>
<Table.Cell>
<span className="block">{line.account}</span>
<span className="block font-mono text-xs text-muted-foreground">
{line.accountCode}
</span>
</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
{line.debit === null ? "—" : `${line.debit.toLocaleString()}`}
</Table.Cell>
<Table.Cell align="right" className="tabular-nums">
{line.credit === null ? "—" : `${line.credit.toLocaleString()}`}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Card.Content>
</Card.Root>
</Tabs.Panel>
</Tabs.Root>
) : (
recordContent
)}
</Layout.Column>
<Layout.Column area="right">
{/* Omitted entirely in terminal states — the alert above carries the
explanation, so the rail never quietly empties. */}
{actions.length > 0 && <ActionPanel title="Actions" actions={actions} />}
{externalSource && (
<Card.Root>
<Card.Header title={externalSource.system} />
<Card.Content>
<a
href={externalSource.href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
>
{externalSource.recordLabel}
<ExternalLink className="size-3" />
</a>
<p className="mt-1 text-xs text-muted-foreground">
Synced {externalSource.syncedAt.toLocaleDateString()}
</p>
</Card.Content>
</Card.Root>
)}
{/* Capped by the component, so a long trail never displaces the card. */}
<ActivityCard title="History" items={order.activities} groupBy="day" maxVisible={6} />
</Layout.Column>
</Layout>
);
}Open questions
Two placement rules from the earlier draft are held back for the team rather than stated as constraints. Both are defensible either way, and both would be enforced across every detail screen once settled.
Team input needed — one home per action. The draft said an action lives in exactly one place: if "Create goods receipt" sits in the actions, it does not also sit in the header of the goods-receipts card. The argument for is that two homes make neither canonical. The argument against is that an action is most discoverable next to the records it produces, and Denim Tears deliberately puts it in both, on the grounds that "the action lives where its results are listed".
- [ ] Decide whether an action may appear both in the actions panel and in the card that lists its results
Team input needed — what
Layout.Headercarries. The draft said the title and nothing else: no status badge, no buttons, with status in the summary and every action in the right-hand column. That matches all three implementations reviewed, none of which puts anything actionable in the header. The open part is whether a record's primary status belongs in the header for at-a-glance reading, and whether a single primary call to action ever earns a place there.
- [ ] Decide what may sit in
Layout.Headerbesides the title — status badge, primary action, neither
Constraints
- Every main-column section sits in a
Card.Root— exceptDescriptionCard, which contains itself, andAlert, which is a banner. No bare<div>sections. - A table in a card needs ONE geometry change, not two. Zero the card's padding (
Card.Content className="px-0!", or dropCard.Content) and leave the table container alone.Table.HeadandTable.Cellalready inset their own first and last cells by 24px; padding on the table container stacks on top of that and pushes the first column 24px right of the card title. - Bare
YYYY-MM-DDdates must not usetype: "date".DescriptionCardhands the value tonew Date(...), which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text viarender. Real timestamps keeptype: "date", withemptyBehavior: "hide"when nullable. ActionPanelis workflow-only. No navigation, no "view related record" — those are links in the cards that hold them.- Never rely on a query cap to bound the line-items table. If the cap can be reached, page the lines.
- A page-level Save belongs to a form, not here. Edits commit per field, per group, or through a sub-route.
- Handle all three states. Loading, error with a retry, and not-found are part of the page. A secondary query — a total computed across other records — degrades its own card and must not take the page down with it.
Anti-patterns
- A "Back to …" entry in the
ActionPanel, or any back affordance in the screen's top-right — the breadcrumb owns navigation, top left. - An action panel that empties in a terminal state with nothing explaining why — or one kept alive by navigation entries so it doesn't look empty.
- A control on a derived status — a select or "mark received" button over a value another module owns.
- A field the schema can't answer, invented to fill the summary's grid.
- Dropping an empty field from the middle of a summary section, so the fields after it shift position between one record and the next.
- A
Table.Footertotal over quantities in mixed units, or a currency symbol on a record with no currency — fabrication that reads as polish. - A
lines(first: 1000)cap standing in for pagination on a document type that can exceed it. - Padding on a table container inside a card (
containerClassName="px-6") — double-pads the first column. type: "date"on a date-only string — renders a day early in negative-offset timezones.- Splitting a record's own sections across tab panels, or putting the actions beside tabs so they appear to apply to the open panel.
- A card for a single parent record — one parent is a linked field, which says it more clearly.
- Hiding a related-record card once the relationship is possible — an empty card with a named empty state is information; a missing card isn't.
- Collapsing a record's own status and its derived ones into one field, or rendering all of them as filled badges.
- A pre-computed disabled state over a refusal the server owns — a dead button with no explanation.
- A dialog stricter than the command behind it, blocking the path the server would have accepted.
- A
DataTablewith toolbar and pagination for a dozen line items that were fetched with the record. - Load-bearing content in the right-hand column, which becomes a footer below 1024px.
- An internal identifier or "see docs/…" pointer visible to an end user.