@tailor-platform/app-shell 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE.md +21 -0
  2. package/dist/app-shell-core.css +1 -1
  3. package/dist/app-shell.d.ts +1203 -316
  4. package/dist/app-shell.js +4562 -3307
  5. package/dist/{appshell-CtS8Y3LW.js → appshell-B0wh5Lq0.js} +850 -549
  6. package/dist/style-entry.d.ts +118 -98
  7. package/dist/testing.d.ts +118 -98
  8. package/dist/testing.js +1 -1
  9. package/dist/themes/_template.css +1 -1
  10. package/dist/vite-plugin.d.ts +118 -98
  11. package/package.json +13 -13
  12. package/skills/app-shell-patterns/SKILL.md +108 -24
  13. package/skills/app-shell-patterns/references/api/create-ai-gateway-client.md +115 -0
  14. package/skills/app-shell-patterns/references/api/create-typed-paths.md +117 -0
  15. package/skills/app-shell-patterns/references/api/define-i18n-labels.md +143 -0
  16. package/skills/app-shell-patterns/references/api/define-module.md +247 -0
  17. package/skills/app-shell-patterns/references/api/define-resource.md +307 -0
  18. package/skills/app-shell-patterns/references/api/guards/hidden.md +202 -0
  19. package/skills/app-shell-patterns/references/api/guards/overview.md +354 -0
  20. package/skills/app-shell-patterns/references/api/guards/pass.md +129 -0
  21. package/skills/app-shell-patterns/references/api/guards/redirect-to.md +307 -0
  22. package/skills/app-shell-patterns/references/api/router/use-location.md +240 -0
  23. package/skills/app-shell-patterns/references/api/router/use-navigate.md +138 -0
  24. package/skills/app-shell-patterns/references/api/router/use-params.md +218 -0
  25. package/skills/app-shell-patterns/references/api/use-ai-chat.md +110 -0
  26. package/skills/app-shell-patterns/references/api/use-app-shell-config.md +126 -0
  27. package/skills/app-shell-patterns/references/api/use-app-shell-data.md +146 -0
  28. package/skills/app-shell-patterns/references/api/use-app-shell.md +143 -0
  29. package/skills/app-shell-patterns/references/api/use-auth.md +145 -0
  30. package/skills/app-shell-patterns/references/api/use-override-breadcrumb.md +74 -0
  31. package/skills/app-shell-patterns/references/api/use-page-meta.md +96 -0
  32. package/skills/app-shell-patterns/references/api/use-register-command-palette-actions.md +90 -0
  33. package/skills/app-shell-patterns/references/api/use-resolved-locale.md +67 -0
  34. package/skills/app-shell-patterns/references/api/use-route-error.md +214 -0
  35. package/skills/app-shell-patterns/references/api/use-theme.md +108 -0
  36. package/skills/app-shell-patterns/references/api/use-time-zone.md +80 -0
  37. package/skills/app-shell-patterns/references/api/use-toast.md +107 -0
  38. package/skills/app-shell-patterns/references/components/action-panel.md +168 -0
  39. package/skills/app-shell-patterns/references/components/activity-card.md +142 -0
  40. package/skills/app-shell-patterns/references/components/ai-chat.md +315 -0
  41. package/skills/app-shell-patterns/references/components/alert.md +202 -0
  42. package/skills/app-shell-patterns/references/components/app-shell.md +530 -0
  43. package/skills/app-shell-patterns/references/components/appearance-switcher.md +87 -0
  44. package/skills/app-shell-patterns/references/components/attachment.md +138 -0
  45. package/skills/app-shell-patterns/references/components/autocomplete.md +278 -0
  46. package/skills/app-shell-patterns/references/components/avatar.md +80 -0
  47. package/skills/app-shell-patterns/references/components/badge.md +309 -0
  48. package/skills/app-shell-patterns/references/components/button.md +164 -0
  49. package/skills/app-shell-patterns/references/components/card.md +109 -0
  50. package/skills/app-shell-patterns/references/components/checkbox.md +169 -0
  51. package/skills/app-shell-patterns/references/components/combobox.md +318 -0
  52. package/skills/app-shell-patterns/references/components/command-palette.md +413 -0
  53. package/skills/app-shell-patterns/references/components/csv-importer.md +214 -0
  54. package/skills/app-shell-patterns/references/components/data-table.md +953 -0
  55. package/skills/app-shell-patterns/references/components/date-picker.md +248 -0
  56. package/skills/app-shell-patterns/references/components/default-header.md +75 -0
  57. package/skills/app-shell-patterns/references/components/default-sidebar.md +136 -0
  58. package/skills/app-shell-patterns/references/components/description-card.md +569 -0
  59. package/skills/app-shell-patterns/references/components/dialog.md +164 -0
  60. package/skills/app-shell-patterns/references/components/document-progress-card.md +154 -0
  61. package/skills/app-shell-patterns/references/components/form.md +378 -0
  62. package/skills/app-shell-patterns/references/components/global-header-layout.md +132 -0
  63. package/skills/app-shell-patterns/references/components/grid.md +184 -0
  64. package/skills/app-shell-patterns/references/components/input.md +83 -0
  65. package/skills/app-shell-patterns/references/components/layout.md +539 -0
  66. package/skills/app-shell-patterns/references/components/menu.md +218 -0
  67. package/skills/app-shell-patterns/references/components/metric-card.md +94 -0
  68. package/skills/app-shell-patterns/references/components/select.md +283 -0
  69. package/skills/app-shell-patterns/references/components/sheet.md +237 -0
  70. package/skills/app-shell-patterns/references/components/sidebar-group.md +466 -0
  71. package/skills/app-shell-patterns/references/components/sidebar-item.md +458 -0
  72. package/skills/app-shell-patterns/references/components/sidebar-layout.md +258 -0
  73. package/skills/app-shell-patterns/references/components/spinner.md +81 -0
  74. package/skills/app-shell-patterns/references/components/table.md +188 -0
  75. package/skills/app-shell-patterns/references/components/tabs.md +158 -0
  76. package/skills/app-shell-patterns/references/components/textarea.md +122 -0
  77. package/skills/app-shell-patterns/references/components/timeline.md +317 -0
  78. package/skills/app-shell-patterns/references/components/toolbar.md +138 -0
  79. package/skills/app-shell-patterns/references/components/tooltip.md +144 -0
  80. package/skills/app-shell-patterns/references/components/with-guard.md +445 -0
  81. package/skills/app-shell-patterns/references/concepts/authentication.md +301 -0
  82. package/skills/app-shell-patterns/references/concepts/custom-components.md +59 -0
  83. package/skills/app-shell-patterns/references/concepts/file-based-routing.md +350 -0
  84. package/skills/app-shell-patterns/references/{fundamental → concepts}/graphql.md +11 -4
  85. package/skills/app-shell-patterns/references/concepts/modules-and-resources.md +229 -0
  86. package/skills/app-shell-patterns/references/concepts/routing-navigation.md +387 -0
  87. package/skills/app-shell-patterns/references/concepts/sidebar-navigation.md +388 -0
  88. package/skills/app-shell-patterns/references/{fundamental/design-system.md → concepts/styling-theming.md} +260 -105
  89. package/skills/app-shell-patterns/references/migrations.md +34 -0
  90. package/skills/app-shell-patterns/references/pages/document-detail.md +1125 -0
  91. package/skills/app-shell-patterns/references/patterns/form-composer.md +44 -46
  92. package/skills/app-shell-patterns/references/patterns/form-modal.md +39 -100
  93. package/skills/app-shell-patterns/references/patterns/form-sectioned.md +12 -38
  94. package/skills/app-shell-patterns/references/patterns/form-single-page.md +11 -32
  95. package/skills/app-shell-patterns/references/patterns/form-wizard.md +12 -53
  96. package/skills/app-shell-patterns/references/patterns/interaction-confirm.md +11 -27
  97. package/skills/app-shell-patterns/references/patterns/interaction-multi-select.md +14 -28
  98. package/skills/app-shell-patterns/references/patterns/interaction-toast.md +10 -34
  99. package/skills/app-shell-patterns/references/patterns/list-dense-scan.md +11 -86
  100. package/skills/app-shell-patterns/references/fundamental/components.md +0 -981
  101. package/skills/app-shell-patterns/references/patterns/detail-hero-with-actions.md +0 -201
@@ -0,0 +1,1125 @@
1
+ ---
2
+ title: Document Detail Page
3
+ description: The screen for a single record — its summary, its contents, the records around it, and the actions available on it
4
+ ---
5
+
6
+ <!-- GENERATED by docs-kit from docs-src/pages/document-detail.docs.outline.md — do not edit by hand. -->
7
+
8
+ # Document Detail Page
9
+
10
+ The screen for a **single record**. Two columns: the record itself on the left,
11
+ and on the right the actions available on it plus the context that sits around
12
+ it rather than inside it — where it is mirrored in another system, who has
13
+ changed it.
14
+
15
+ It is not only for documents. Orders, receipts and invoices use the fullest
16
+ version of it; master-data records — a supplier, an item, a site — use the same
17
+ shape with fewer sections, typically a summary and a few actions and nothing
18
+ else. What follows covers the full version; skip what a given record doesn't
19
+ have.
20
+
21
+ ## When to Use
22
+
23
+ - A screen showing exactly one record
24
+ - The record has a lifecycle someone moves it through, or other records created from it
25
+ - Someone reading the record needs to understand its current state, and may want to act on it
26
+
27
+ ## What the page answers
28
+
29
+ The records this applies to vary widely in what they mean and what they carry.
30
+ For example, a purchase order might carry prices and an approval chain where a
31
+ goods receipt carries neither. What stays the same is the sequence someone works
32
+ through when they open the page, and the layout follows that sequence:
33
+
34
+ 1. **What is this record, and is it still in progress?** → the alerts, then the summary
35
+ 2. **What is in it?** → the line items
36
+ 3. **What is it connected to, and what has it caused?** → its sources, the records created from it, its accounting entries
37
+ 4. **What can I do about it?** → the actions in the right-hand column
38
+
39
+ A record skips whichever of these it doesn't have. It never reorders the ones it
40
+ does.
41
+
42
+ ## High-level layout
43
+
44
+ An example, with more cards filled in than most records will have:
45
+
46
+ ```
47
+ +---------------------------------------------------------+
48
+ | Layout.Header Purchase order PO-24118 breadcrumb |
49
+ +----------------------------------+----------------------+
50
+ | Layout.Column (main) | Layout.Column right |
51
+ | Alert terminal states only | ActionPanel |
52
+ | | Duplicate |
53
+ | DescriptionCard Summary | Amend / Edit |
54
+ | identity · statuses | Create receipt |
55
+ | supplier → link | Close |
56
+ | | |
57
+ | Card Source documents (up) | Card External |
58
+ | Card Line items | QBO · Bill 1042 |
59
+ | Card Goods receipts (down) | |
60
+ | Card Journal | ActivityCard |
61
+ | Card Reference documents | History |
62
+ | | |
63
+ +----------------------------------+----------------------+
64
+ ```
65
+
66
+ Below 1024px wide, the right-hand column drops beneath the main one. That is why
67
+ nothing needed to _understand_ the record may live only on the right: that column
68
+ carries actions and surrounding context, not content.
69
+
70
+ ## Main column
71
+
72
+ Cards in this order. Skip what doesn't apply; don't reorder what does.
73
+
74
+ | Card | Appears when | Job |
75
+ | --------------------- | ------------------------------------------------------------ | -------------------------------------------------- |
76
+ | Status alerts | A terminal state, or one whose consequence is invisible | Explain the missing actions before they're hunted |
77
+ | Summary | Always | Identity and current state |
78
+ | Upstream / exceptions | The line items only make sense in context; a block is active | Where the contents came from; what's holding it up |
79
+ | Line items | The record has contents | The record's own content |
80
+ | Related records | The relationship is possible | What this record has caused |
81
+ | Reference documents | Files hang off the record | Attachments, with a viewer link |
82
+
83
+ The **summary is the only card that always appears.** Line items are on most
84
+ documents but not on all records — a supplier or a site has no contents to list,
85
+ and simply omits the card rather than showing an empty one.
86
+
87
+ ### 1. Status alerts — above everything
88
+
89
+ When a record reaches a state that removes its actions, say so. Otherwise the
90
+ action list silently shortens and the reader goes looking for a button that is no
91
+ longer there.
92
+
93
+ Write one alert per state, not a single generic "this record is closed":
94
+
95
+ - **Settled / completed** → `success`. The work finished and the figures are frozen.
96
+ - **Cancelled / voided** → `neutral`. Nothing happened; a new record is needed.
97
+ - **Still in progress, but the next step can't be undone** → `info`. Also for a control someone would expect that genuinely doesn't exist.
98
+ - **Rejected back to draft** → `warning`. Without it, a bounced record is indistinguishable from a fresh one.
99
+
100
+ These are **persistent, not dismissible**. The alert is derived from the
101
+ record's state, so it belongs on screen for exactly as long as the record is in
102
+ that state — don't pass `dismissible`. Transient feedback after an action is
103
+ `useToast`, never an `Alert`.
104
+
105
+ `Alert` is compound: `Alert.Root variant` wrapping `Alert.Title` and
106
+ `Alert.Description`.
107
+
108
+ ### 2. Summary — always first
109
+
110
+ One `DescriptionCard`, `columns={3}`, titled for the record ("Purchase order
111
+ information"). Self-containing — never wrap it in a `Card.Root`. Field order:
112
+
113
+ 1. **Identity** — the document number or code first, `meta: { copyable: true }`, because it's the thing people paste into chat
114
+ 2. **Statuses** — the record's own, then any derived ones (see below)
115
+ 3. **The other party and place** — the supplier, customer or site the record is with, as a `type: "link"`. Point `hrefKey` at an href computed before render, so a record whose counterpart can't be resolved shows plain text rather than a dead link
116
+ 4. **A `{ type: "divider" }`**, then dates, commercial terms and external references
117
+
118
+ > **Only fields the record can actually answer.** A goods receipt has no
119
+ > supplier and no path to one, so it gets a _count of source documents_ instead,
120
+ > and the supplier lives on the order the receipt points at. Deriving a field
121
+ > from data already loaded — a line count, an order total, a resolved source set
122
+ > — is fine and common. Inventing one the schema can't answer is how a page
123
+ > ships broken.
124
+
125
+ #### Its own status, and the ones it derives
126
+
127
+ A record usually shows more than one status. It has its **own** status — where
128
+ it is in its own lifecycle — and it often also shows **derived** statuses that
129
+ summarise the state of related records or external events: whether the ordered
130
+ goods have arrived, whether the invoice has been paid, whether a sync to an
131
+ external system succeeded.
132
+
133
+ Show each one as its own field with its own badge. Collapsing them into a single
134
+ value loses exactly what the page exists to convey. The record's own status is a
135
+ filled semantic variant; the derived ones are `outline-*`, so the reader can see
136
+ at a glance which status the record itself owns.
137
+
138
+ > **Never put a control on a derived status.** Not a select, not a "mark as
139
+ > received" action, not an override. Those values are written by whichever module
140
+ > owns receiving, or billing, or the integration — a control here would claim an
141
+ > ownership this screen doesn't have. Render the value, and put the records behind
142
+ > it in a related-records card.
143
+
144
+ #### Conditional and empty fields
145
+
146
+ A field with no value renders an em dash by default (`emptyBehavior: "dash"`).
147
+ The alternatives are to hide it (`emptyBehavior: "hide"`) or to leave it out of
148
+ the array entirely — and both move everything after it.
149
+
150
+ `DescriptionCard` lays its fields out as a grid in DOM order, so **removing a
151
+ field shifts every field after it into the vacated slot**. A field that sat third
152
+ in a row moves to second. Two records of the same type can then present the same
153
+ information in different positions, which is exactly what a summary card should
154
+ not do.
155
+
156
+ So:
157
+
158
+ - **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.
159
+ - **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.
160
+ - **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.
161
+
162
+ A second `DescriptionCard` is right when a group of fields has a different owner
163
+ or a different edit path from the first — system-derived versus operator-entered.
164
+
165
+ ### 3. Upstream sources and exceptions
166
+
167
+ **Upstream sources** are the documents this record was created from. They go
168
+ _above_ the line items when the line items only make sense once you know where
169
+ they came from. A goods receipt is the clearest case: each of its lines is a
170
+ receipt _of_ a specific purchase-order line, so the quantities mean nothing until
171
+ the reader can see which orders are being received against. Show each source
172
+ with its document number, its status badge, and a link out.
173
+
174
+ **Exceptions and blocks** are anything actively stopping the record from moving
175
+ on — a payment hold, a failed validation. They go directly under the summary,
176
+ given the destructive (red) treatment because they are a problem to clear rather
177
+ than information to read, and rendered only when at least one is active. Each
178
+ hold is listed as its own entry, and the control that releases it sits beside
179
+ its reason. This is the one place an action belongs in the main column rather
180
+ than in the right-hand column: releasing a hold is done _to the hold_, one at a
181
+ time, and the record is unblocked only once every one is released.
182
+
183
+ ### 4. Line items
184
+
185
+ `Card.Root` → `Card.Header` (title, plus a one-line description where the
186
+ columns need explaining) → `Card.Content className="px-0!"` → `Table.Root`.
187
+
188
+ Fetch the line items with the record, sorted explicitly so the order is stable
189
+ across reloads, and render them as a plain `Table`. For the typical document —
190
+ a handful to a few dozen lines — that is right: a toolbar and pagination would
191
+ add controls with nothing to do.
192
+
193
+ Some document types carry far more, so size the table for the document type's
194
+ realistic maximum rather than for the record in front of you. Where a line count
195
+ can run to hundreds, bound the table: give it an internal scroll region so the
196
+ card doesn't grow without limit, and page the lines where scrolling alone would
197
+ be unwieldy. A query cap such as `lines(first: 1000)` bounds the query, not the
198
+ table — a record that reaches it shows an incomplete set with nothing on screen
199
+ saying so, and it is never a substitute for scrolling or paging.
200
+
201
+ > **Team input needed — a shared line-items component.** Every app builds this
202
+ > table by hand today, and the read and edit versions drift apart. The erp-kit
203
+ > templates carry the closest thing to a starting point: a form-bound
204
+ > `LineItemsTable` with optional client-side paging and a footer that mirrors
205
+ > `DataTable.Pagination`. Nothing in AppShell yet covers reading and editing
206
+ > line items, with paging, in one component.
207
+ >
208
+ > - [ ] Decide whether AppShell should own a line-items component, and what it covers
209
+
210
+ Columns, left to right:
211
+
212
+ 1. **Identity** — the item name, with the SKU beneath it in muted mono `text-xs`. Two facts, one column.
213
+ 2. **The record's own numbers** — quantity, unit of measure, unit price. Every document has these.
214
+ 3. **Numbers from related records**, where they exist and the reader needs them (see below).
215
+ 4. **Subtotal**, derived, last.
216
+
217
+ Numeric columns take `align="right"` on **both** the head and the cell, and
218
+ `tabular-nums` so digits line up. An empty table is an explicit muted paragraph
219
+ inside the card, never a bare header row.
220
+
221
+ #### Numbers from related records
222
+
223
+ A line can also carry figures that come from _other_ documents. Which ones —
224
+ if any — depends on where the record sits in its chain:
225
+
226
+ - **A record that others fulfil** (an order) can show what has happened against each line: received, billed, shipped.
227
+ - **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.
228
+ - **Many records show neither** and carry only their own numbers.
229
+
230
+ These are not a fixed part of the table — implementations differ on the same
231
+ document type — so include them only where those related records exist and the
232
+ reader is reconciling against them. Where a figure is shown against a target,
233
+ put the difference beside the actual figure as muted text rather than adding a
234
+ separate variance column.
235
+
236
+ > **A total row is a claim, not a decoration.** Add `Table.Footer` only where
237
+ > the column genuinely sums. An order priced in one currency totals cleanly. A
238
+ > goods receipt often cannot total its quantities at all: one shipment might take
239
+ > 6 cases of one item and 120 kilograms of another, and there is no unit those
240
+ > add up in. Likewise omit a currency symbol on a record whose schema has no
241
+ > currency to qualify it. Both mistakes look like polish and read as
242
+ > fabrication.
243
+
244
+ ### 5. Related records — what this one caused
245
+
246
+ One card per _type_ of related record: receipts against an order, invoices
247
+ against a receipt, payments applied, the instalments still due, the accounting
248
+ entries the record produced. Each is a small table of document number → status
249
+ → date → the figure that matters, with the number linking to that record's own
250
+ page.
251
+
252
+ - 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.
253
+ - Order the cards the way the work runs — goods movements before money movements
254
+ - The empty state names the relationship ("No goods receipts linked to this order"), so the reader learns the relationship exists and is simply unused
255
+ - 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
256
+ - Group by parent where the hierarchy is real — orders, then the receipts under each
257
+
258
+ > **Team input needed — how much these tables should do.** They are written
259
+ > here as pointers: identify the related record, link to it, stop. The
260
+ > alternative is letting them carry enough columns to answer a question without
261
+ > leaving the page, at the cost of a second place that reports on the same data.
262
+ > Where a related collection genuinely needs filtering and paging, the fallback
263
+ > is `pattern/list/dense-scan` on its own route, linked from here.
264
+ >
265
+ > - [ ] Decide how far a related-records table goes before it becomes its own screen
266
+
267
+ #### When a relationship is a card, and when it is a field on the summary
268
+
269
+ Not every related record deserves a card. The question comes up for every
270
+ relationship the record has, and the answer follows from how many records are on
271
+ the other end:
272
+
273
+ **A relationship to one other record is a link field on the summary. A
274
+ relationship to many other records needs a card.**
275
+
276
+ Typically, if the record has a parent it has only one, so the parent appears as
277
+ a link on the summary description card. If it has child records there are many
278
+ of them and their statuses matter, so they go in a table inside their own card —
279
+ which is more than a field could carry.
280
+
281
+ The exception worth knowing is many records _upstream_: a receipt consolidating
282
+ three orders, an invoice raised against several receipts. That is many records on
283
+ the other end, so it takes a card of its own, placed above the line items. Count,
284
+ not direction, is what decides.
285
+
286
+ The other case for a card is a relationship the API cannot follow. A
287
+ polymorphic link — a `sourceType` / `sourceId` pair with no union type and no
288
+ relation field on either side — cannot be traversed in a GraphQL query in either
289
+ direction, so it has to be resolved by the page. A field cannot express it.
290
+
291
+ If you find yourself building a card to display a single supplier, that
292
+ relationship belongs on the summary instead.
293
+
294
+ #### Accounting entries
295
+
296
+ A record that gets posted to the ledger produces accounting entries, and those
297
+ belong on the record. Before posting they are a _preview_ computed from what the
298
+ page already holds, and must be labelled as one. After posting they are the real
299
+ entries, fetched by document number — and posting often books more than one, so
300
+ fetch the whole set rather than the obvious one.
301
+
302
+ #### Opening a related record
303
+
304
+ A related record's document number **links through** to that record's own page.
305
+ That is the convention across every implementation reviewed, and the default
306
+ here. Add an open-in-a-new-tab affordance on each entry — Omakase pins a
307
+ new-tab control as a column and offers the same through a right-click menu — so
308
+ someone comparing records can keep this one open.
309
+
310
+ > **Team input needed — the cross-checking case.** Someone matching an invoice
311
+ > against its receipts wants to glance at each receipt without losing their
312
+ > place. Link-through handles that only via a new tab. The alternatives seen are
313
+ > a modal over the current page (the erp-kit journal entry — the sole exception
314
+ > among 25 collection tables there, and that record has a detail route as well)
315
+ > and a Sheet, which Denim Tears uses for revision history rather than for
316
+ > related records.
317
+ >
318
+ > - [ ] 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
319
+
320
+ ### 6. Reference documents
321
+
322
+ Files that hang off the record — a supplier's signed contract, a scanned
323
+ delivery note, a photo of damaged goods. One card titled **Reference
324
+ documents**, near the end of the main column, listing each file with a viewer
325
+ link, and an upload control gated on the record's state.
326
+
327
+ ## Secondary (right-hand) column
328
+
329
+ Ordered from what the reader acts on to what they only consult, because below
330
+ 1024px this column becomes the page's footer.
331
+
332
+ ### 1. `ActionPanel` — first, titled "Actions"
333
+
334
+ Everything here does something _to_ this record: changes it, moves it through its
335
+ lifecycle, or creates the record that comes next in the workflow.
336
+
337
+ A serviceable starting set, in order:
338
+
339
+ | Action | When |
340
+ | --------------------- | ------------------------------------------------- |
341
+ | Download PDF | records that get sent to another party |
342
+ | Duplicate | almost always |
343
+ | Edit | while the record is still a draft |
344
+ | Amend | once it's committed and a change has consequences |
345
+ | Create «child record» | when the lifecycle allows the next document |
346
+ | Close / Cancel | the terminal transitions |
347
+
348
+ Icons come from `lucide-react`, one per verb. Use the same glyph for the same
349
+ verb on every record, so people learn the panel once:
350
+
351
+ | Verb | Icon |
352
+ | --------------------------- | ----------------------------------------------------------------------- |
353
+ | Edit (a draft) | `Pencil` |
354
+ | Amend (a committed record) | `FileEdit` |
355
+ | Duplicate | `Copy` |
356
+ | Download PDF | `Download` |
357
+ | Delete (a draft) | `Trash2` |
358
+ | Submit / Post / send onward | `Send` |
359
+ | Approve / Confirm | `Check` |
360
+ | Reject | `Ban` |
361
+ | Cancel / Close | `XCircle` |
362
+ | Redraft / reopen | `RotateCcw` |
363
+ | Release (a hold) | `Unlock` |
364
+ | Receive | `PackageCheck` |
365
+ | Create «goods receipt» | `PackagePlus` |
366
+ | Deactivate / Reactivate | `UserX` / `UserCheck` for people, `ShieldOff` / `ShieldCheck` for roles |
367
+ | Revision history | `History` |
368
+
369
+ Pass the bare element — `icon: <Pencil />` — and let `ActionPanel` size it.
370
+
371
+ **Never a back button — not here, and nowhere else in the top-right.** Two
372
+ separate reasons, and each is sufficient on its own. Navigation is not an action
373
+ on the record, so it does not belong in a panel of them; and the top-right of a
374
+ screen is not where anyone looks to go back. Back is the breadcrumb's job, top
375
+ left, where the reader already came from. A panel opening with "Back to orders"
376
+ also trains people to read it as a menu rather than as the set of things they can
377
+ do to what they are looking at.
378
+
379
+ Mechanics:
380
+
381
+ - **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.
382
+ - **Bind `loading`** on any entry that fires a mutation, from that mutation's own in-flight state; `variant="destructive"` for the destructive ones.
383
+ - **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.
384
+ - **Report both outcomes.** Success and failure both toast; no silent writes.
385
+ - **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.
386
+ - **Export a predicate** (`hasOrderActions(status)`) alongside the panel, so the screen can decide whether it renders at all.
387
+ - `ActionItem` isn't exported; annotate an actions array as `ActionPanelProps["actions"]`.
388
+
389
+ **Terminal states.** The lifecycle actions all drop out, and it's the alert at
390
+ the top of the record — not the panel's own thinness — that explains their
391
+ absence. The always-available utilities legitimately outlive the workflow: a
392
+ settled order can still be duplicated or downloaded, so the panel often survives
393
+ carrying only those. Render it when something is genuinely in it, omit it
394
+ entirely when nothing is, and never pad it with navigation to stop it looking
395
+ bare.
396
+
397
+ ### 2. External system — under the actions
398
+
399
+ When the record is mirrored outside the platform — Shopify, QuickBooks, a WMS —
400
+ one card naming the system, linking to the record over there, and saying when it
401
+ last synced. Omit the card entirely when there's no such link; never render it
402
+ empty.
403
+
404
+ A dedicated component for this is tracked as
405
+ [tailor-inc/platform-planning#775 (Integration Card)](https://github.com/tailor-inc/platform-planning/issues/775),
406
+ which covers the integration's identity and icon, its mapped fields, external
407
+ links and status indicators. Until it lands, compose the card by hand as above.
408
+
409
+ ### 3. History — last
410
+
411
+ Revisions, or an audit trail of who changed what, as an `ActivityCard`. It's
412
+ context, not content: someone who never opens it should still understand the
413
+ record without it.
414
+
415
+ **One treatment, whatever the length.** A trail's length varies from record to
416
+ record of the same type, so it can't decide the presentation — two records of
417
+ one type showing history in two different places is worse than either choice on
418
+ its own. `ActivityCard` caps itself: `maxVisible` (6 by default) bounds what it
419
+ renders and the rest collapses behind an overflow label. Set the cap and leave
420
+ it in the column.
421
+
422
+ ## Optional cards
423
+
424
+ Nothing additional is required; add one only when the record calls for it.
425
+
426
+ - **Metric strip** — headline figures. Either lead the main column with `MetricCard`s in a `Grid` (`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.
427
+ - **`DocumentProgressCard`** — a lifecycle or fulfilment breakdown. Derive `percent` and `segments` in the consumer.
428
+ - **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.
429
+
430
+ ## Editing
431
+
432
+ **Prefer editing in place.** Where a field is editable in the record's current
433
+ state, give it an affordance — a pencil beside the value — and swap it for an
434
+ input where it already sits. The value doesn't move between reading and editing,
435
+ so someone learns one screen position per field instead of two, and the page
436
+ doesn't rearrange itself around the act of editing.
437
+
438
+ That is the default. Escalate only when the edit genuinely can't be done a field
439
+ at a time:
440
+
441
+ | Scope | Mechanism |
442
+ | ---------------------------------------------------- | ----------------------------------------------------------- |
443
+ | One field, or a handful independent of each other | **In place**, on the summary or the line |
444
+ | A coherent group that must validate or save together | A **dialog** opened from the actions |
445
+ | The whole record and all its line items | A **sub-route** rendering this screen with the form over it |
446
+
447
+ What is editable is always the product of two things — the record's lifecycle
448
+ state and the reader's permission — resolved into named booleans once, near the
449
+ top of the component, and not re-derived inline. Name them for what they permit
450
+ (`lineItemsEditable`) or reveal (`showReceiptsCard`), so the JSX reads as intent
451
+ rather than as a chain of status comparisons.
452
+
453
+ In practice a draft is broadly editable in place, and a committed record still
454
+ has a few fields that are: a delivery date, a note, a currency, a reference
455
+ someone needs to correct without amending the whole record.
456
+
457
+ > **Team input needed — in-place editing versus an edit route.** The guidance
458
+ > above prefers in-place, and separately describes `/edit` and `/amend`
459
+ > sub-routes. The two pull in different directions: if fields toggle between
460
+ > reading and editing where they sit, it isn't obvious what a whole-record edit
461
+ > form is still for, or how someone chooses between them. Both exist in the
462
+ > field today.
463
+ >
464
+ > - [ ] Decide where the boundary sits between editing in place and opening an edit route, and whether both should be offered on the same record
465
+
466
+ A sub-route is an edit form with a URL of its own that opens as a dialog over
467
+ this screen: `/edit` and `/amend` render the same record behind the form, so the
468
+ reader keeps their context and the URL stays shareable. Two things it has to get
469
+ right:
470
+
471
+ - **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.
472
+ - **Every dismissal path funnels through one handler** — close, cancel, Escape, backdrop, and a successful save all return to the detail URL.
473
+
474
+ Even fully editable, the screen still reads as a record with editable fields, not
475
+ as a form: the cards keep their order, and nothing collects a page-level Save at
476
+ the bottom.
477
+
478
+ ## When a tab strip is warranted
479
+
480
+ **One scrolling column is the shape.** Everything about the record — its
481
+ summary, its contents, its related records — belongs on that one column, and a
482
+ tab strip is not a way to tidy it up. A strip is never an alternative view of
483
+ data that belongs on the cards.
484
+
485
+ A second tab is warranted when **the record caused a separate accounting or
486
+ inventory record, and that record is the subject of the tab.** The cases that
487
+ come up in practice:
488
+
489
+ | Second tab | Holds |
490
+ | ------------------ | ------------------------------------------------------ |
491
+ | Stock movement | The inventory transactions posting this record created |
492
+ | Journal / GL entry | The general-ledger entries posting this record booked |
493
+
494
+ Those qualify because the tab's subject is a _different record type_ with its
495
+ own identity and its own lifecycle — not another view of this one. Another case
496
+ may meet the same test; if it does, it belongs here too. What settles it is
497
+ whether you can name the other record type the tab is about. If you can't, there
498
+ is no second tab.
499
+
500
+ Explicitly not grounds for a strip:
501
+
502
+ - The column is long, or scrolling feels like a lot
503
+ - The related-record cards are numerous
504
+ - A group of fields feels like it deserves its own space
505
+ - Someone would like a "details" tab and an "activity" tab
506
+
507
+ Two rules come with a strip that does qualify, and both follow from the same
508
+ point: **the page's subject is the primary record, and a second tab shows
509
+ records it produced.** Those are consequences, read-only here — to act on one,
510
+ open its own page.
511
+
512
+ 1. **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.
513
+ 2. **The `ActionPanel` stays 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.
514
+
515
+ A third rule is practical: if the second tab's content goes away — nothing
516
+ posted yet, so no journal — the strip goes with it. **A one-tab strip is worse
517
+ than no tabs.**
518
+
519
+ Where a long column genuinely needs wayfinding, the affordance is still a strip
520
+ of tabs across the top — but they scroll rather than switch. Every card stays on
521
+ the one page; selecting a tab scrolls that card into view, and the active tab
522
+ follows the reader's scroll position. It looks like a tab strip and behaves like
523
+ a table of contents. AppShell ships no primitive for it, so treat it as a
524
+ considered addition rather than a default.
525
+
526
+ ## Links
527
+
528
+ One treatment everywhere on the page — `DescriptionCard`'s own link fields, the
529
+ document numbers in related-record tables, the external-system card:
530
+
531
+ **`text-primary` at rest, underline only on hover** — the primary colour is what
532
+ says "clickable" without a hover, and the resting underline is noise. Add
533
+ `underline-offset-4` so the hover underline clears descenders.
534
+
535
+ - **Internal** (another route in the app) → the `Link` exported by app-shell, with `to`. Never a bare `<a href>` for an in-app route.
536
+ - **External** (another system) → `<a target="_blank" rel="noopener noreferrer">` with a lucide `ExternalLink` at `size-3` after the label, so the new tab is signposted. There is no dedicated external-link component; this is the whole recipe.
537
+ - **Identifiers** keep their `font-mono` on top of the link style.
538
+ - **A `render` field owns its typography.** `DescriptionCard` applies none of its value styling to custom output, so a bare string from `render` reads a size larger than the values around it. Wrap it in `text-sm font-medium text-foreground` — the reference implementation's `Value` helper.
539
+
540
+ ## Patterns for the parts
541
+
542
+ The page decides the frame; these build what sits in it.
543
+
544
+ | Need | Pattern |
545
+ | -------------------------------------------------- | --------------------------------------- |
546
+ | A related collection big enough for its own screen | `pattern/list/dense-scan` |
547
+ | Editing a coherent group of header fields | `pattern/form/modal` |
548
+ | A full edit of the record and its line items | `pattern/form/sectioned` on a sub-route |
549
+ | Confirming a destructive transition | `pattern/interaction/confirm` |
550
+ | Reporting an action's outcome | `pattern/interaction/toast` |
551
+
552
+ ## Reference implementation
553
+
554
+ A confirmed purchase order: terminal-state alerts, a summary with its own status
555
+ plus two derived ones and a link to its source, a many-upstream card above the
556
+ line items, line items showing received quantities from the order's receipts and
557
+ a total that legitimately sums, a related-records card, and a right-hand column
558
+ of actions, external-system link and history.
559
+
560
+ ```tsx
561
+ /* page: detail */
562
+ import {
563
+ ActionPanel,
564
+ ActivityCard,
565
+ Alert,
566
+ Badge,
567
+ Button,
568
+ Card,
569
+ DescriptionCard,
570
+ Layout,
571
+ Link,
572
+ Table,
573
+ Tabs,
574
+ } from "@tailor-platform/app-shell";
575
+ import type { ActionPanelProps } from "@tailor-platform/app-shell";
576
+ import {
577
+ Copy,
578
+ ExternalLink,
579
+ FileEdit,
580
+ FileText,
581
+ PackagePlus,
582
+ Pencil,
583
+ Unlock,
584
+ XCircle,
585
+ } from "lucide-react";
586
+ import type React from "react";
587
+ import type { ExternalSource, PurchaseOrder } from "./mock";
588
+
589
+ type Props = {
590
+ order: PurchaseOrder;
591
+ /** Omitted when the record has no third-party counterpart. */
592
+ externalSource?: ExternalSource;
593
+ onEdit: () => void;
594
+ onAmend: () => void;
595
+ onDuplicate: () => void;
596
+ onClose: () => void;
597
+ onCreateGoodsReceipt: () => void;
598
+ onReleaseHold: (holdId: string) => void;
599
+ closing: boolean;
600
+ };
601
+
602
+ /**
603
+ * Bare `Date` scalars arrive as "YYYY-MM-DD". `type: "date"` would hand them to
604
+ * `new Date(value)`, which reads them as UTC midnight and renders the previous
605
+ * day west of Greenwich — so format them as plain text instead. Real `DateTime`
606
+ * fields keep `type: "date"`.
607
+ */
608
+ const formatDay = (value: string) =>
609
+ new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
610
+ year: "numeric",
611
+ month: "short",
612
+ day: "numeric",
613
+ });
614
+
615
+ /**
616
+ * A `render` field owns its own presentation — DescriptionCard applies none of
617
+ * the built-in value typography to it — so match that style here or the two
618
+ * dates read a size larger than every other value on the card.
619
+ */
620
+ const Value = ({ children }: { children: React.ReactNode }) => (
621
+ <span className="text-sm font-medium text-foreground">{children}</span>
622
+ );
623
+
624
+ /** House link: primary colour at rest, underline only on hover. */
625
+ const DOC_LINK = "font-mono text-xs text-primary underline-offset-4 hover:underline";
626
+
627
+ export default function PurchaseOrderDetailPage({
628
+ order,
629
+ externalSource,
630
+ onEdit,
631
+ onAmend,
632
+ onDuplicate,
633
+ onClose,
634
+ onCreateGoodsReceipt,
635
+ onReleaseHold,
636
+ closing,
637
+ }: Props) {
638
+ const isDraft = order.orderStatus === "DRAFT";
639
+ const isConfirmed = order.orderStatus === "CONFIRMED";
640
+ const isSettled = order.orderStatus === "SETTLED";
641
+ const isCancelled = order.orderStatus === "CANCELLED";
642
+ const hasJournal = order.journalLines.length > 0;
643
+
644
+ // Assembled by status-gated spreads, so the rail only ever offers what is
645
+ // legal now. Nothing here navigates — the breadcrumb owns that.
646
+ const actions: ActionPanelProps["actions"] = [
647
+ { key: "duplicate", label: "Duplicate", icon: <Copy />, onClick: onDuplicate },
648
+ ...(isDraft ? [{ key: "edit", label: "Edit", icon: <Pencil />, onClick: onEdit }] : []),
649
+ ...(isConfirmed
650
+ ? [
651
+ { key: "amend", label: "Amend", icon: <FileEdit />, onClick: onAmend },
652
+ {
653
+ key: "create-goods-receipt",
654
+ label: "Create goods receipt",
655
+ icon: <PackagePlus />,
656
+ onClick: onCreateGoodsReceipt,
657
+ },
658
+ {
659
+ key: "close",
660
+ label: "Close order",
661
+ icon: <XCircle />,
662
+ onClick: onClose,
663
+ // Bound to the mutation's own in-flight state. The action stays
664
+ // enabled even though the server may refuse — a guessed disabled
665
+ // state drifts from the command and explains nothing.
666
+ loading: closing,
667
+ variant: "destructive" as const,
668
+ },
669
+ ]
670
+ : []),
671
+ ];
672
+
673
+ const recordContent = (
674
+ <>
675
+ {/* An active block, directly under the summary and styled
676
+ destructive: it's a problem to clear, not information to read.
677
+ Each hold is released on its own, beside its own reason. */}
678
+ {order.holds.length > 0 && (
679
+ <Card.Root className="border-destructive">
680
+ <Card.Header title="On hold" />
681
+ <Card.Content className="flex flex-col gap-3">
682
+ {order.holds.map((hold) => (
683
+ <div key={hold.id} className="flex items-start justify-between gap-4">
684
+ <div>
685
+ <p className="text-sm font-medium text-foreground">{hold.reason}</p>
686
+ <p className="text-xs text-muted-foreground">
687
+ Placed by {hold.placedBy} on {formatDay(hold.placedAt)}
688
+ </p>
689
+ </div>
690
+ <Button
691
+ size="sm"
692
+ variant="outline"
693
+ onClick={() => onReleaseHold(hold.id)}
694
+ className="shrink-0"
695
+ >
696
+ <Unlock className="size-4" />
697
+ Release
698
+ </Button>
699
+ </div>
700
+ ))}
701
+ </Card.Content>
702
+ </Card.Root>
703
+ )}
704
+
705
+ <DescriptionCard
706
+ title="Purchase order information"
707
+ data={order}
708
+ columns={3}
709
+ fields={[
710
+ { key: "docNumber", label: "Order number", meta: { copyable: true } },
711
+ {
712
+ key: "orderStatus",
713
+ label: "Status",
714
+ type: "badge",
715
+ meta: {
716
+ badgeVariantMap: {
717
+ DRAFT: "neutral",
718
+ CONFIRMED: "info",
719
+ SETTLED: "success",
720
+ CANCELLED: "error",
721
+ },
722
+ },
723
+ },
724
+ // Progress axes, owned by other modules. Rendered, never controlled.
725
+ {
726
+ key: "receiptStatus",
727
+ label: "Receipt",
728
+ type: "badge",
729
+ meta: {
730
+ badgeVariantMap: { PARTIAL: "outline-warning", COMPLETE: "outline-success" },
731
+ },
732
+ },
733
+ {
734
+ key: "billingStatus",
735
+ label: "Billing",
736
+ type: "badge",
737
+ meta: {
738
+ badgeVariantMap: { UNBILLED: "outline-neutral", BILLED: "outline-success" },
739
+ },
740
+ },
741
+ {
742
+ key: "supplier",
743
+ label: "Supplier",
744
+ type: "link",
745
+ meta: { hrefKey: "supplierHref" },
746
+ },
747
+ { type: "divider" },
748
+ { key: "total", label: "Total", type: "money", meta: { currencyKey: "currency" } },
749
+ // Bare Date scalars, pre-formatted rather than typed as dates.
750
+ {
751
+ key: "orderDate",
752
+ label: "Ordered",
753
+ render: (o) => <Value>{formatDay(o.orderDate)}</Value>,
754
+ },
755
+ {
756
+ key: "expectedDate",
757
+ label: "Expected",
758
+ render: (o) => <Value>{formatDay(o.expectedDate)}</Value>,
759
+ },
760
+ // A real DateTime, and nullable, so it keeps type: "date".
761
+ {
762
+ key: "confirmedAt",
763
+ label: "Confirmed",
764
+ type: "date",
765
+ meta: { dateFormat: "medium" },
766
+ emptyBehavior: "hide",
767
+ },
768
+ ]}
769
+ />
770
+
771
+ {/* Upstream sits above the lines, because the lines came from it and
772
+ can't be read without it. Several sources, so a card — a single one
773
+ would have been a link field on the summary instead. */}
774
+ <Card.Root>
775
+ <Card.Header title="Source requisitions" />
776
+ <Card.Content className="px-0!">
777
+ <Table.Root>
778
+ <Table.Header>
779
+ <Table.Row>
780
+ <Table.Head>Requisition</Table.Head>
781
+ <Table.Head>Status</Table.Head>
782
+ <Table.Head>Raised</Table.Head>
783
+ </Table.Row>
784
+ </Table.Header>
785
+ <Table.Body>
786
+ {order.sourceDocuments.map((source) => (
787
+ <Table.Row key={source.id}>
788
+ <Table.Cell>
789
+ <Link to={source.href} className={DOC_LINK}>
790
+ {source.docNumber}
791
+ </Link>
792
+ </Table.Cell>
793
+ <Table.Cell>
794
+ <Badge variant="success">{source.status}</Badge>
795
+ </Table.Cell>
796
+ <Table.Cell>{formatDay(source.raisedAt)}</Table.Cell>
797
+ </Table.Row>
798
+ ))}
799
+ </Table.Body>
800
+ </Table.Root>
801
+ </Card.Content>
802
+ </Card.Root>
803
+
804
+ {/* The document's own content. Identity, then its own numbers, then the
805
+ received quantity that comes from this order's receipts, then the
806
+ derived subtotal. A plain Table: this order has a handful of lines,
807
+ fetched with the record — see the entry for when a line set is large
808
+ enough to page. No container padding; the cells inset themselves. */}
809
+ <Card.Root>
810
+ <Card.Header title="Line items" />
811
+ <Card.Content className="px-0!">
812
+ {order.lineItems.length === 0 ? (
813
+ <p className="px-6 text-sm text-muted-foreground">No line items on this order.</p>
814
+ ) : (
815
+ <Table.Root>
816
+ <Table.Header>
817
+ <Table.Row>
818
+ <Table.Head>Item</Table.Head>
819
+ <Table.Head align="right">Qty</Table.Head>
820
+ <Table.Head>Unit</Table.Head>
821
+ <Table.Head align="right">Unit price</Table.Head>
822
+ <Table.Head align="right">Received</Table.Head>
823
+ <Table.Head align="right">Subtotal</Table.Head>
824
+ </Table.Row>
825
+ </Table.Header>
826
+ <Table.Body>
827
+ {order.lineItems.map((line) => (
828
+ <Table.Row key={line.id}>
829
+ {/* Two facts, one column. */}
830
+ <Table.Cell>
831
+ <span className="block">{line.itemName}</span>
832
+ <span className="block font-mono text-xs text-muted-foreground">
833
+ {line.sku}
834
+ </span>
835
+ </Table.Cell>
836
+ <Table.Cell align="right" className="tabular-nums">
837
+ {line.qty}
838
+ </Table.Cell>
839
+ <Table.Cell>{line.unit}</Table.Cell>
840
+ <Table.Cell align="right" className="tabular-nums">
841
+ ${line.unitPrice.toLocaleString()}
842
+ </Table.Cell>
843
+ {/* Actual, with a muted delta beside it when it differs. */}
844
+ <Table.Cell align="right" className="tabular-nums">
845
+ {line.received}
846
+ {line.received !== line.qty && (
847
+ <span className="ml-1 text-xs text-muted-foreground">
848
+ ({line.qty - line.received})
849
+ </span>
850
+ )}
851
+ </Table.Cell>
852
+ <Table.Cell align="right" className="tabular-nums">
853
+ ${line.subtotal.toLocaleString()}
854
+ </Table.Cell>
855
+ </Table.Row>
856
+ ))}
857
+ </Table.Body>
858
+ {/* A footer only because these subtotals genuinely sum — one
859
+ currency, one additive column. */}
860
+ <Table.Footer>
861
+ <Table.Row>
862
+ <Table.Cell>Total</Table.Cell>
863
+ <Table.Cell colSpan={4} />
864
+ <Table.Cell align="right" className="tabular-nums">
865
+ ${order.total.toLocaleString()}
866
+ </Table.Cell>
867
+ </Table.Row>
868
+ </Table.Footer>
869
+ </Table.Root>
870
+ )}
871
+ </Card.Content>
872
+ </Card.Root>
873
+
874
+ {/* Downstream: what this order has caused. Shown empty once the
875
+ relationship is possible, so the reader learns it exists. */}
876
+ <Card.Root>
877
+ <Card.Header title="Goods receipts" />
878
+ <Card.Content className="px-0!">
879
+ {order.goodsReceipts.length === 0 ? (
880
+ <p className="px-6 text-sm text-muted-foreground">
881
+ No goods receipts linked to this order.
882
+ </p>
883
+ ) : (
884
+ <Table.Root>
885
+ <Table.Header>
886
+ <Table.Row>
887
+ <Table.Head>Receipt</Table.Head>
888
+ <Table.Head>Status</Table.Head>
889
+ <Table.Head>Received</Table.Head>
890
+ <Table.Head align="right">Qty</Table.Head>
891
+ </Table.Row>
892
+ </Table.Header>
893
+ <Table.Body>
894
+ {order.goodsReceipts.map((receipt) => (
895
+ <Table.Row key={receipt.id}>
896
+ <Table.Cell>
897
+ <Link to={receipt.href} className={DOC_LINK}>
898
+ {receipt.docNumber}
899
+ </Link>
900
+ </Table.Cell>
901
+ <Table.Cell>
902
+ <Badge variant="success">{receipt.status}</Badge>
903
+ </Table.Cell>
904
+ <Table.Cell>{formatDay(receipt.receivedAt)}</Table.Cell>
905
+ <Table.Cell align="right" className="tabular-nums">
906
+ {receipt.qty}
907
+ </Table.Cell>
908
+ </Table.Row>
909
+ ))}
910
+ </Table.Body>
911
+ </Table.Root>
912
+ )}
913
+ </Card.Content>
914
+ </Card.Root>
915
+ {/* Files attached to the record, near the end of the column. */}
916
+ <Card.Root>
917
+ <Card.Header title="Reference documents" />
918
+ <Card.Content className="flex flex-col gap-2">
919
+ {order.referenceDocuments.map((file) => (
920
+ <div key={file.id} className="flex items-center justify-between gap-4">
921
+ <a
922
+ href={file.href}
923
+ target="_blank"
924
+ rel="noopener noreferrer"
925
+ className="inline-flex items-center gap-2 text-sm text-primary underline-offset-4 hover:underline"
926
+ >
927
+ <FileText className="size-4 shrink-0" />
928
+ {file.name}
929
+ <ExternalLink className="size-3 shrink-0" />
930
+ </a>
931
+ <span className="shrink-0 text-xs text-muted-foreground">
932
+ {file.sizeLabel} · {formatDay(file.uploadedAt)}
933
+ </span>
934
+ </div>
935
+ ))}
936
+ </Card.Content>
937
+ </Card.Root>
938
+ </>
939
+ );
940
+
941
+ return (
942
+ <Layout>
943
+ <Layout.Header title={`Purchase order ${order.docNumber}`} />
944
+ <Layout.Column>
945
+ {/* Terminal states explain themselves, above everything else, so nobody
946
+ hunts for actions that are gone. Each state is its own message. */}
947
+ {isSettled && (
948
+ <Alert.Root variant="success">
949
+ <Alert.Title>Settled</Alert.Title>
950
+ <Alert.Description>
951
+ Every line has been received and billed. The figures below are final.
952
+ </Alert.Description>
953
+ </Alert.Root>
954
+ )}
955
+ {/* Live, but the next transition is irreversible — worth saying before
956
+ someone reaches for Close. */}
957
+ {isConfirmed && (
958
+ <Alert.Root variant="info">
959
+ <Alert.Title>Closing this order cannot be undone</Alert.Title>
960
+ <Alert.Description>
961
+ Any quantity still outstanding is written off when the order closes.
962
+ </Alert.Description>
963
+ </Alert.Root>
964
+ )}
965
+ {isCancelled && (
966
+ <Alert.Root variant="neutral">
967
+ <Alert.Title>Cancelled</Alert.Title>
968
+ <Alert.Description>
969
+ This order was cancelled before it was acted on. Raise a new one to reorder.
970
+ </Alert.Description>
971
+ </Alert.Root>
972
+ )}
973
+
974
+ {/* The strip sits below the alerts and inside the main column, so it
975
+ governs only this column — the actions on the right stay outside it,
976
+ and can't read as applying to the tab. Posting books the journal, so
977
+ an unposted order has one tab's worth of content and no strip. */}
978
+ {hasJournal ? (
979
+ <Tabs.Root defaultValue="record" variant="line" className="flex flex-col gap-4">
980
+ {/* self-start: Tabs.List centres its tabs inside a full-width box by
981
+ default, which reads as centred against left-aligned cards. */}
982
+ <Tabs.List aria-label="Purchase order sections" className="self-start">
983
+ <Tabs.Tab value="record">Purchase order</Tabs.Tab>
984
+ <Tabs.Tab value="journal">Journal entry</Tabs.Tab>
985
+ </Tabs.List>
986
+
987
+ <Tabs.Panel value="record" className="flex flex-col gap-4">
988
+ {recordContent}
989
+ </Tabs.Panel>
990
+
991
+ {/* A different record type, with its own identity — which is what
992
+ qualifies it for a tab rather than another card. Read-only here:
993
+ acting on it means opening the journal entry's own page. */}
994
+ <Tabs.Panel value="journal">
995
+ <Card.Root>
996
+ <Card.Header
997
+ title="Journal entry"
998
+ description="Booked when this order was posted."
999
+ />
1000
+ <Card.Content className="px-0!">
1001
+ <Table.Root>
1002
+ <Table.Header>
1003
+ <Table.Row>
1004
+ <Table.Head>Account</Table.Head>
1005
+ <Table.Head align="right">Debit</Table.Head>
1006
+ <Table.Head align="right">Credit</Table.Head>
1007
+ </Table.Row>
1008
+ </Table.Header>
1009
+ <Table.Body>
1010
+ {order.journalLines.map((line) => (
1011
+ <Table.Row key={line.id}>
1012
+ <Table.Cell>
1013
+ <span className="block">{line.account}</span>
1014
+ <span className="block font-mono text-xs text-muted-foreground">
1015
+ {line.accountCode}
1016
+ </span>
1017
+ </Table.Cell>
1018
+ <Table.Cell align="right" className="tabular-nums">
1019
+ {line.debit === null ? "—" : `${line.debit.toLocaleString()}`}
1020
+ </Table.Cell>
1021
+ <Table.Cell align="right" className="tabular-nums">
1022
+ {line.credit === null ? "—" : `${line.credit.toLocaleString()}`}
1023
+ </Table.Cell>
1024
+ </Table.Row>
1025
+ ))}
1026
+ </Table.Body>
1027
+ </Table.Root>
1028
+ </Card.Content>
1029
+ </Card.Root>
1030
+ </Tabs.Panel>
1031
+ </Tabs.Root>
1032
+ ) : (
1033
+ recordContent
1034
+ )}
1035
+ </Layout.Column>
1036
+
1037
+ <Layout.Column area="right">
1038
+ {/* Omitted entirely in terminal states — the alert above carries the
1039
+ explanation, so the rail never quietly empties. */}
1040
+ {actions.length > 0 && <ActionPanel title="Actions" actions={actions} />}
1041
+
1042
+ {externalSource && (
1043
+ <Card.Root>
1044
+ <Card.Header title={externalSource.system} />
1045
+ <Card.Content>
1046
+ <a
1047
+ href={externalSource.href}
1048
+ target="_blank"
1049
+ rel="noopener noreferrer"
1050
+ className="inline-flex items-center gap-1 text-sm text-primary underline-offset-4 hover:underline"
1051
+ >
1052
+ {externalSource.recordLabel}
1053
+ <ExternalLink className="size-3" />
1054
+ </a>
1055
+ <p className="mt-1 text-xs text-muted-foreground">
1056
+ Synced {externalSource.syncedAt.toLocaleDateString()}
1057
+ </p>
1058
+ </Card.Content>
1059
+ </Card.Root>
1060
+ )}
1061
+
1062
+ {/* Capped by the component, so a long trail never displaces the card. */}
1063
+ <ActivityCard title="History" items={order.activities} groupBy="day" maxVisible={6} />
1064
+ </Layout.Column>
1065
+ </Layout>
1066
+ );
1067
+ }
1068
+ ```
1069
+
1070
+ ## Open questions
1071
+
1072
+ Two placement rules from the earlier draft are held back for the team rather
1073
+ than stated as constraints. Both are defensible either way, and both would be
1074
+ enforced across every detail screen once settled.
1075
+
1076
+ > **Team input needed — one home per action.** The draft said an action lives
1077
+ > in exactly one place: if "Create goods receipt" sits in the actions, it does
1078
+ > not also sit in the header of the goods-receipts card. The argument for is
1079
+ > that two homes make neither canonical. The argument against is that an action
1080
+ > is most discoverable next to the records it produces, and Denim Tears
1081
+ > deliberately puts it in both, on the grounds that "the action lives where its
1082
+ > results are listed".
1083
+ >
1084
+ > - [ ] Decide whether an action may appear both in the actions panel and in the card that lists its results
1085
+
1086
+ > **Team input needed — what `Layout.Header` carries.** The draft said the
1087
+ > title and nothing else: no status badge, no buttons, with status in the
1088
+ > summary and every action in the right-hand column. That matches all three
1089
+ > implementations reviewed, none of which puts anything actionable in the
1090
+ > header. The open part is whether a record's primary status belongs in the
1091
+ > header for at-a-glance reading, and whether a single primary call to action
1092
+ > ever earns a place there.
1093
+ >
1094
+ > - [ ] Decide what may sit in `Layout.Header` besides the title — status badge, primary action, neither
1095
+
1096
+ ## Constraints
1097
+
1098
+ - **Every main-column section sits in a `Card.Root`** — except `DescriptionCard`, which contains itself, and `Alert`, which is a banner. No bare `<div>` sections.
1099
+ - **A table in a card needs ONE geometry change, not two.** Zero the card's padding (`Card.Content className="px-0!"`, or drop `Card.Content`) and leave the table container alone. `Table.Head` and `Table.Cell` already 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.
1100
+ - **Bare `YYYY-MM-DD` dates must not use `type: "date"`.** `DescriptionCard` hands the value to `new Date(...)`, which reads a date-only string as UTC midnight and renders the previous day west of Greenwich. Pre-format those as text via `render`. Real timestamps keep `type: "date"`, with `emptyBehavior: "hide"` when nullable.
1101
+ - **`ActionPanel` is workflow-only.** No navigation, no "view related record" — those are links in the cards that hold them.
1102
+ - **Never rely on a query cap to bound the line-items table.** If the cap can be reached, page the lines.
1103
+ - **A page-level Save belongs to a form, not here.** Edits commit per field, per group, or through a sub-route.
1104
+ - **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.
1105
+
1106
+ ## Anti-patterns
1107
+
1108
+ - A "Back to …" entry in the `ActionPanel`, or any back affordance in the screen's top-right — the breadcrumb owns navigation, top left.
1109
+ - 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.
1110
+ - A control on a derived status — a select or "mark received" button over a value another module owns.
1111
+ - A field the schema can't answer, invented to fill the summary's grid.
1112
+ - Dropping an empty field from the middle of a summary section, so the fields after it shift position between one record and the next.
1113
+ - A `Table.Footer` total over quantities in mixed units, or a currency symbol on a record with no currency — fabrication that reads as polish.
1114
+ - A `lines(first: 1000)` cap standing in for pagination on a document type that can exceed it.
1115
+ - Padding on a table container inside a card (`containerClassName="px-6"`) — double-pads the first column.
1116
+ - `type: "date"` on a date-only string — renders a day early in negative-offset timezones.
1117
+ - Splitting a record's own sections across tab panels, or putting the actions beside tabs so they appear to apply to the open panel.
1118
+ - A card for a single parent record — one parent is a linked field, which says it more clearly.
1119
+ - 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.
1120
+ - Collapsing a record's own status and its derived ones into one field, or rendering all of them as filled badges.
1121
+ - A pre-computed disabled state over a refusal the server owns — a dead button with no explanation.
1122
+ - A dialog stricter than the command behind it, blocking the path the server would have accepted.
1123
+ - A `DataTable` with toolbar and pagination for a dozen line items that were fetched with the record.
1124
+ - Load-bearing content in the right-hand column, which becomes a footer below 1024px.
1125
+ - An internal identifier or "see docs/…" pointer visible to an end user.